From b4ab3df2ec85fbe477d7721344a8cd3630b437a1 Mon Sep 17 00:00:00 2001 From: nsfisis Date: Sun, 16 Aug 2026 13:59:28 +0900 Subject: feat(plugin): guard Rust-owned classes the worker has no proxy for The worker's autoloader fell through to the real Composer source for every Rust-owned FQCN without a proxy stub, so plugin code doing `new Filesystem()` or subclassing `LibraryInstaller` silently ran on a second instance the Rust side never sees. An unimplemented part of the plugin API has to fail with an explicit error naming it, not quietly work on a disconnected copy. The stub generator now emits a guard class for each of those FQCNs: the real declaration, hierarchy and constants, with every constructor and method raising an explicit error. References satisfied by the declaration alone (`instanceof`, `X::class`, `Link::TYPE_REQUIRE`) keep working. Two FQCNs stay resolvable to the real class, each listed with the worker-side mechanism that makes a natively constructed instance correct. The error had nowhere to go: `Installer::run` dropped the `Result` of both `dispatch_script` calls, so an exception from a listener ended in exit 0. Both propagate now, the way the exception does upstream. Three real-plugin E2E comparisons stop at a guard and are ignored, each naming the class it needs. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 5 +- crates/shirabe-php-rpc/build.rs | 20 ++ .../php/guards/Composer/Advisory/AuditConfig.php | 24 ++ .../Composer/Advisory/IgnoredSecurityAdvisory.php | 25 ++ .../Composer/Advisory/PartialSecurityAdvisory.php | 31 +++ .../guards/Composer/Advisory/SecurityAdvisory.php | 30 +++ .../guards/Composer/Autoload/AutoloadGenerator.php | 143 ++++++++++++ .../shirabe-php-rpc/php/guards/Composer/Cache.php | 110 +++++++++ .../php/guards/Composer/Compiler.php | 22 ++ .../guards/Composer/Config/JsonConfigSource.php | 74 ++++++ .../Composer/DependencyResolver/Decisions.php | 145 ++++++++++++ .../Composer/DependencyResolver/DefaultPolicy.php | 55 +++++ .../Composer/DependencyResolver/GenericRule.php | 107 +++++++++ .../DependencyResolver/LocalRepoTransaction.php | 20 ++ .../DependencyResolver/LockTransaction.php | 37 +++ .../DependencyResolver/MultiConflictRule.php | 107 +++++++++ .../guards/Composer/DependencyResolver/Pool.php | 115 ++++++++++ .../Composer/DependencyResolver/PoolBuilder.php | 37 +++ .../Composer/DependencyResolver/PoolOptimizer.php | 22 ++ .../guards/Composer/DependencyResolver/Request.php | 126 ++++++++++ .../Composer/DependencyResolver/Rule2Literals.php | 107 +++++++++ .../guards/Composer/DependencyResolver/RuleSet.php | 78 +++++++ .../DependencyResolver/RuleSetGenerator.php | 65 ++++++ .../DependencyResolver/RuleSetIterator.php | 42 ++++ .../SecurityAdvisoryPoolFilter.php | 25 ++ .../guards/Composer/DependencyResolver/Solver.php | 58 +++++ .../Composer/DependencyResolver/Transaction.php | 39 ++++ .../guards/Composer/Downloader/FileDownloader.php | 105 +++++++++ .../Composer/Downloader/FossilDownloader.php | 100 ++++++++ .../guards/Composer/Downloader/GitDownloader.php | 149 ++++++++++++ .../guards/Composer/Downloader/GzipDownloader.php | 45 ++++ .../guards/Composer/Downloader/HgDownloader.php | 100 ++++++++ .../guards/Composer/Downloader/PathDownloader.php | 48 ++++ .../Composer/Downloader/PerforceDownloader.php | 111 +++++++++ .../guards/Composer/Downloader/PharDownloader.php | 45 ++++ .../guards/Composer/Downloader/RarDownloader.php | 45 ++++ .../guards/Composer/Downloader/SvnDownloader.php | 110 +++++++++ .../guards/Composer/Downloader/TarDownloader.php | 45 ++++ .../guards/Composer/Downloader/XzDownloader.php | 45 ++++ .../guards/Composer/Downloader/ZipDownloader.php | 55 +++++ .../php/guards/Composer/Factory.php | 134 +++++++++++ .../IgnoreListPlatformRequirementFilter.php | 34 +++ .../php/guards/Composer/Installer.php | 253 +++++++++++++++++++++ .../guards/Composer/Installer/BinaryInstaller.php | 66 ++++++ .../guards/Composer/Installer/InstallerEvent.php | 47 ++++ .../guards/Composer/Installer/LibraryInstaller.php | 104 +++++++++ .../Composer/Installer/MetapackageInstaller.php | 66 ++++++ .../guards/Composer/Installer/PluginInstaller.php | 64 ++++++ .../guards/Composer/Installer/ProjectInstaller.php | 68 ++++++ .../php/guards/Composer/Json/JsonFile.php | 81 +++++++ .../Package/Archiver/ArchivableFilesFinder.php | 24 ++ .../Composer/Package/Archiver/ArchiveManager.php | 51 +++++ .../Composer/Package/Archiver/PharArchiver.php | 27 +++ .../Composer/Package/Archiver/ZipArchiver.php | 27 +++ .../guards/Composer/Package/Loader/ArrayLoader.php | 40 ++++ .../Composer/Package/Loader/RootPackageLoader.php | 39 ++++ .../Package/Loader/ValidatingArrayLoader.php | 44 ++++ .../php/guards/Composer/Package/Locker.php | 113 +++++++++ .../Composer/Package/Version/VersionBumper.php | 25 ++ .../Composer/Package/Version/VersionSelector.php | 32 +++ .../php/guards/Composer/Plugin/CommandEvent.php | 36 +++ .../php/guards/Composer/Plugin/PluginManager.php | 118 ++++++++++ .../Composer/Plugin/PostFileDownloadEvent.php | 50 ++++ .../Composer/Plugin/PreFileDownloadEvent.php | 65 ++++++ .../guards/Composer/Plugin/PrePoolCreateEvent.php | 70 ++++++ .../Composer/Repository/ArtifactRepository.php | 34 +++ .../Composer/Repository/ComposerRepository.php | 108 +++++++++ .../Composer/Repository/CompositeRepository.php | 80 +++++++ .../Composer/Repository/FilterRepository.php | 80 +++++++ .../Composer/Repository/InstalledRepository.php | 39 ++++ .../Composer/Repository/LockArrayRepository.php | 27 +++ .../Composer/Repository/PackageRepository.php | 37 +++ .../guards/Composer/Repository/PathRepository.php | 38 ++++ .../guards/Composer/Repository/PearRepository.php | 17 ++ .../Composer/Repository/PlatformRepository.php | 63 +++++ .../Composer/Repository/RepositoryFactory.php | 58 +++++ .../guards/Composer/Repository/RepositorySet.php | 93 ++++++++ .../Composer/Repository/RootPackageRepository.php | 24 ++ .../Composer/Repository/Vcs/ForgejoDriver.php | 122 ++++++++++ .../Composer/Repository/Vcs/FossilDriver.php | 116 ++++++++++ .../Composer/Repository/Vcs/GitBitbucketDriver.php | 137 +++++++++++ .../guards/Composer/Repository/Vcs/GitDriver.php | 106 +++++++++ .../Composer/Repository/Vcs/GitHubDriver.php | 147 ++++++++++++ .../Composer/Repository/Vcs/GitLabDriver.php | 165 ++++++++++++++ .../guards/Composer/Repository/Vcs/HgDriver.php | 106 +++++++++ .../Composer/Repository/Vcs/PerforceDriver.php | 117 ++++++++++ .../guards/Composer/Repository/Vcs/SvnDriver.php | 121 ++++++++++ .../guards/Composer/Repository/VcsRepository.php | 70 ++++++ .../php/guards/Composer/Util/AuthHelper.php | 45 ++++ .../php/guards/Composer/Util/Bitbucket.php | 42 ++++ .../php/guards/Composer/Util/ConfigValidator.php | 27 +++ .../php/guards/Composer/Util/Filesystem.php | 167 ++++++++++++++ .../php/guards/Composer/Util/Git.php | 100 ++++++++ .../php/guards/Composer/Util/GitHub.php | 52 +++++ .../php/guards/Composer/Util/GitLab.php | 40 ++++ .../guards/Composer/Util/Http/CurlDownloader.php | 37 +++ .../php/guards/Composer/Util/Http/CurlResponse.php | 22 ++ .../php/guards/Composer/Util/Http/Response.php | 57 +++++ .../php/guards/Composer/Util/HttpDownloader.php | 86 +++++++ .../php/guards/Composer/Util/Loop.php | 39 ++++ .../php/guards/Composer/Util/Perforce.php | 219 ++++++++++++++++++ .../php/guards/Composer/Util/Platform.php | 92 ++++++++ .../php/guards/Composer/Util/ProcessExecutor.php | 114 ++++++++++ .../php/guards/Composer/Util/RemoteFilesystem.php | 85 +++++++ .../php/guards/Composer/Util/Svn.php | 82 +++++++ crates/shirabe-php-rpc/php/worker.php | 38 +++- crates/shirabe-php-rpc/src/lib.rs | 22 +- crates/shirabe-php-rpc/tests/generated_stubs.rs | 6 +- crates/shirabe/src/installer.rs | 4 +- .../tests/plugin/e2e_extension_installer_test.rs | 4 + crates/shirabe/tests/plugin/e2e_installers_test.rs | 7 + crates/shirabe/tests/plugin/e2e_normalize_test.rs | 10 +- docs/dev/php-rpc.md | 12 +- docs/dev/plugin-class-classification.md | 6 + docs/dev/plugin-stub-generation.md | 52 ++++- scripts/plugin-stub-generator/generate-stubs | 118 +++++++--- .../plugin-stub-generator/guard-exemptions.list | 14 ++ scripts/plugin-stub-generator/src/Generator.php | 29 +-- .../plugin-stub-generator/src/GuardGenerator.php | 215 +++++++++++++++++ scripts/plugin-stub-generator/src/Report.php | 33 ++- scripts/plugin-stub-generator/src/SourceFile.php | 21 ++ 121 files changed, 8061 insertions(+), 91 deletions(-) create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/Advisory/AuditConfig.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/Advisory/IgnoredSecurityAdvisory.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/Advisory/PartialSecurityAdvisory.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/Advisory/SecurityAdvisory.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/Autoload/AutoloadGenerator.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/Cache.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/Compiler.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/Config/JsonConfigSource.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/DependencyResolver/Decisions.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/DependencyResolver/DefaultPolicy.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/DependencyResolver/GenericRule.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/DependencyResolver/LocalRepoTransaction.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/DependencyResolver/LockTransaction.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/DependencyResolver/MultiConflictRule.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/DependencyResolver/Pool.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/DependencyResolver/PoolBuilder.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/DependencyResolver/PoolOptimizer.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/DependencyResolver/Request.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/DependencyResolver/Rule2Literals.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/DependencyResolver/RuleSet.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/DependencyResolver/RuleSetGenerator.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/DependencyResolver/RuleSetIterator.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/DependencyResolver/SecurityAdvisoryPoolFilter.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/DependencyResolver/Solver.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/DependencyResolver/Transaction.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/Downloader/FileDownloader.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/Downloader/FossilDownloader.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/Downloader/GitDownloader.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/Downloader/GzipDownloader.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/Downloader/HgDownloader.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/Downloader/PathDownloader.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/Downloader/PerforceDownloader.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/Downloader/PharDownloader.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/Downloader/RarDownloader.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/Downloader/SvnDownloader.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/Downloader/TarDownloader.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/Downloader/XzDownloader.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/Downloader/ZipDownloader.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/Factory.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/Filter/PlatformRequirementFilter/IgnoreListPlatformRequirementFilter.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/Installer.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/Installer/BinaryInstaller.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/Installer/InstallerEvent.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/Installer/LibraryInstaller.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/Installer/MetapackageInstaller.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/Installer/PluginInstaller.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/Installer/ProjectInstaller.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/Json/JsonFile.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/Package/Archiver/ArchivableFilesFinder.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/Package/Archiver/ArchiveManager.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/Package/Archiver/PharArchiver.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/Package/Archiver/ZipArchiver.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/Package/Loader/ArrayLoader.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/Package/Loader/RootPackageLoader.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/Package/Loader/ValidatingArrayLoader.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/Package/Locker.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/Package/Version/VersionBumper.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/Package/Version/VersionSelector.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/Plugin/CommandEvent.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/Plugin/PluginManager.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/Plugin/PostFileDownloadEvent.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/Plugin/PreFileDownloadEvent.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/Plugin/PrePoolCreateEvent.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/Repository/ArtifactRepository.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/Repository/ComposerRepository.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/Repository/CompositeRepository.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/Repository/FilterRepository.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/Repository/InstalledRepository.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/Repository/LockArrayRepository.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/Repository/PackageRepository.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/Repository/PathRepository.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/Repository/PearRepository.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/Repository/PlatformRepository.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/Repository/RepositoryFactory.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/Repository/RepositorySet.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/Repository/RootPackageRepository.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/Repository/Vcs/ForgejoDriver.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/Repository/Vcs/FossilDriver.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/Repository/Vcs/GitBitbucketDriver.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/Repository/Vcs/GitDriver.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/Repository/Vcs/GitHubDriver.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/Repository/Vcs/GitLabDriver.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/Repository/Vcs/HgDriver.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/Repository/Vcs/PerforceDriver.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/Repository/Vcs/SvnDriver.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/Repository/VcsRepository.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/Util/AuthHelper.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/Util/Bitbucket.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/Util/ConfigValidator.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/Util/Filesystem.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/Util/Git.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/Util/GitHub.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/Util/GitLab.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/Util/Http/CurlDownloader.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/Util/Http/CurlResponse.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/Util/Http/Response.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/Util/HttpDownloader.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/Util/Loop.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/Util/Perforce.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/Util/Platform.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/Util/ProcessExecutor.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/Util/RemoteFilesystem.php create mode 100644 crates/shirabe-php-rpc/php/guards/Composer/Util/Svn.php create mode 100644 scripts/plugin-stub-generator/guard-exemptions.list create mode 100644 scripts/plugin-stub-generator/src/GuardGenerator.php diff --git a/README.md b/README.md index 9ef03103..12638649 100644 --- a/README.md +++ b/README.md @@ -11,10 +11,7 @@ It aims at 100% compatibility with Composer, including the plugin API. ## Plugins -Shirabe runs Composer plugins using the system-provided real PHP. The plugin API is partially implemented. The following plugins are verified to work: - -* [composer/installers](https://github.com/composer/installers) -* [phpstan/extension-installer](https://github.com/phpstan/extension-installer) +Shirabe runs Composer plugins using the system-provided real PHP. The plugin API is partially implemented. ## Requirements diff --git a/crates/shirabe-php-rpc/build.rs b/crates/shirabe-php-rpc/build.rs index 07a77a07..937d0bb2 100644 --- a/crates/shirabe-php-rpc/build.rs +++ b/crates/shirabe-php-rpc/build.rs @@ -79,6 +79,26 @@ fn main() { ) .unwrap(); println!("cargo::rustc-env=SHIRABE_COMPOSER_RUNTIME_BUNDLE_ID={bundle_id}"); + + write_guard_files(&manifest_dir.join("php/guards"), &out_dir); +} + +/// Writes the initializer of `GUARD_FILES`: one `include_str!` per generated guard class. The +/// list is built here rather than spelled out in the source because there is one guard for every +/// Rust-owned class the worker has no proxy stub for. +fn write_guard_files(guards: &Path, out_dir: &Path) { + println!("cargo::rerun-if-changed={}", guards.display()); + + let mut code = String::from("&[\n"); + for file in find_files(guards) { + code += &format!( + " ({:?}, include_str!({:?})),\n", + relative_path(guards, &file), + file.to_str().unwrap() + ); + } + code += "]\n"; + std::fs::write(out_dir.join("guard-files.rs"), code).unwrap(); } /// Every file the bundle holds, in the order `Compiler::compile` adds them. diff --git a/crates/shirabe-php-rpc/php/guards/Composer/Advisory/AuditConfig.php b/crates/shirabe-php-rpc/php/guards/Composer/Advisory/AuditConfig.php new file mode 100644 index 00000000..d45d56ad --- /dev/null +++ b/crates/shirabe-php-rpc/php/guards/Composer/Advisory/AuditConfig.php @@ -0,0 +1,24 @@ + 'PACKAGE', + self::TYPE_REQUEST => 'REQUEST', + self::TYPE_LEARNED => 'LEARNED', + ]; + + public function __construct() + { + \ShirabeUnsupportedClass::fail(self::class, '__construct'); + } + + public function add(Rule $rule, $type): void + { + \ShirabeUnsupportedClass::fail(self::class, 'add'); + } + + public function count(): int + { + \ShirabeUnsupportedClass::fail(self::class, 'count'); + } + + public function ruleById(int $id): Rule + { + \ShirabeUnsupportedClass::fail(self::class, 'ruleById'); + } + + public function getRules(): array + { + \ShirabeUnsupportedClass::fail(self::class, 'getRules'); + } + + public function getIterator(): RuleSetIterator + { + \ShirabeUnsupportedClass::fail(self::class, 'getIterator'); + } + + public function getIteratorFor($types): RuleSetIterator + { + \ShirabeUnsupportedClass::fail(self::class, 'getIteratorFor'); + } + + public function getIteratorWithout($types): RuleSetIterator + { + \ShirabeUnsupportedClass::fail(self::class, 'getIteratorWithout'); + } + + public function getTypes(): array + { + \ShirabeUnsupportedClass::fail(self::class, 'getTypes'); + } + + public function getPrettyString(?RepositorySet $repositorySet = null, ?Request $request = null, ?Pool $pool = null, bool $isVerbose = false): string + { + \ShirabeUnsupportedClass::fail(self::class, 'getPrettyString'); + } + + public function __toString(): string + { + \ShirabeUnsupportedClass::fail(self::class, '__toString'); + } +} diff --git a/crates/shirabe-php-rpc/php/guards/Composer/DependencyResolver/RuleSetGenerator.php b/crates/shirabe-php-rpc/php/guards/Composer/DependencyResolver/RuleSetGenerator.php new file mode 100644 index 00000000..4406ddda --- /dev/null +++ b/crates/shirabe-php-rpc/php/guards/Composer/DependencyResolver/RuleSetGenerator.php @@ -0,0 +1,65 @@ +https?)://(?P.+?)(?::(?P[0-9]+))?/|git@(?P[^:]+):)(?P.+)/(?P[^/]+?)(?:\.git|/)?$#'; + + public function __construct() + { + \ShirabeUnsupportedClass::fail(self::class, '__construct'); + } + + public function initialize(): void + { + \ShirabeUnsupportedClass::fail(self::class, 'initialize'); + } + + public function setHttpDownloader(HttpDownloader $httpDownloader): void + { + \ShirabeUnsupportedClass::fail(self::class, 'setHttpDownloader'); + } + + public function getComposerInformation(string $identifier): ?array + { + \ShirabeUnsupportedClass::fail(self::class, 'getComposerInformation'); + } + + public function getFileContent(string $file, string $identifier): ?string + { + \ShirabeUnsupportedClass::fail(self::class, 'getFileContent'); + } + + public function getChangeDate(string $identifier): ?\DateTimeImmutable + { + \ShirabeUnsupportedClass::fail(self::class, 'getChangeDate'); + } + + public function getRepositoryUrl(): string + { + \ShirabeUnsupportedClass::fail(self::class, 'getRepositoryUrl'); + } + + public function getUrl(): string + { + \ShirabeUnsupportedClass::fail(self::class, 'getUrl'); + } + + public function getDist(string $identifier): ?array + { + \ShirabeUnsupportedClass::fail(self::class, 'getDist'); + } + + public function getSource(string $identifier): array + { + \ShirabeUnsupportedClass::fail(self::class, 'getSource'); + } + + public function getRootIdentifier(): string + { + \ShirabeUnsupportedClass::fail(self::class, 'getRootIdentifier'); + } + + public function getBranches(): array + { + \ShirabeUnsupportedClass::fail(self::class, 'getBranches'); + } + + public function getTags(): array + { + \ShirabeUnsupportedClass::fail(self::class, 'getTags'); + } + + public function getApiUrl(): string + { + \ShirabeUnsupportedClass::fail(self::class, 'getApiUrl'); + } + + protected function getReferences(string $type): array + { + \ShirabeUnsupportedClass::fail(self::class, 'getReferences'); + } + + protected function fetchProject(): void + { + \ShirabeUnsupportedClass::fail(self::class, 'fetchProject'); + } + + protected function attemptCloneFallback(): bool + { + \ShirabeUnsupportedClass::fail(self::class, 'attemptCloneFallback'); + } + + protected function generateSshUrl(): string + { + \ShirabeUnsupportedClass::fail(self::class, 'generateSshUrl'); + } + + protected function generatePublicUrl(): string + { + \ShirabeUnsupportedClass::fail(self::class, 'generatePublicUrl'); + } + + protected function setupGitDriver(string $url): void + { + \ShirabeUnsupportedClass::fail(self::class, 'setupGitDriver'); + } + + protected function getContents(string $url, bool $fetchingRepoData = false): Response + { + \ShirabeUnsupportedClass::fail(self::class, 'getContents'); + } + + public static function supports(IOInterface $io, Config $config, string $url, bool $deep = false): bool + { + \ShirabeUnsupportedClass::fail(self::class, 'supports'); + } + + public function getRepoData(): ?array + { + \ShirabeUnsupportedClass::fail(self::class, 'getRepoData'); + } + + protected function getNextPage(Response $response): ?string + { + \ShirabeUnsupportedClass::fail(self::class, 'getNextPage'); + } + + protected function shouldCache(string $identifier): bool + { + \ShirabeUnsupportedClass::fail(self::class, 'shouldCache'); + } + + protected function getBaseComposerInformation(string $identifier): ?array + { + \ShirabeUnsupportedClass::fail(self::class, 'getBaseComposerInformation'); + } + + public function hasComposerFile(string $identifier): bool + { + \ShirabeUnsupportedClass::fail(self::class, 'hasComposerFile'); + } + + protected function getScheme(): string + { + \ShirabeUnsupportedClass::fail(self::class, 'getScheme'); + } + + public function cleanup(): void + { + \ShirabeUnsupportedClass::fail(self::class, 'cleanup'); + } +} diff --git a/crates/shirabe-php-rpc/php/guards/Composer/Repository/Vcs/HgDriver.php b/crates/shirabe-php-rpc/php/guards/Composer/Repository/Vcs/HgDriver.php new file mode 100644 index 00000000..18cf388d --- /dev/null +++ b/crates/shirabe-php-rpc/php/guards/Composer/Repository/Vcs/HgDriver.php @@ -0,0 +1,106 @@ + */ @@ -439,18 +459,20 @@ if ($client === false) { stream_set_write_buffer($client, 0); ShirabeRpcRuntime::$socket = $client; ShirabeRpcRuntime::$stubsDir = $argv[2] ?? null; +ShirabeRpcRuntime::$guardsDir = $argv[3] ?? null; // Proxy stub classes take priority over any other autoloader (including autoloaders that a // script or the composer runtime registers later), so a proxied FQCN can never be shadowed by // the real implementation. `__shirabe_require` re-prepends this closure after loading code -// that registers its own prepending autoloader. +// that registers its own prepending autoloader. The guards come after the stubs: they cover the +// Rust-owned classes no stub proxies, so the two directories never hold the same FQCN. ShirabeRpcRuntime::$stubAutoloader = static function (string $class): void { - if (ShirabeRpcRuntime::$stubsDir === null) { - return; - } - $file = ShirabeRpcRuntime::$stubsDir . '/' . str_replace('\\', '/', $class) . '.php'; - if (is_file($file)) { - require $file; + $relative = '/' . str_replace('\\', '/', $class) . '.php'; + foreach ([ShirabeRpcRuntime::$stubsDir, ShirabeRpcRuntime::$guardsDir] as $dir) { + if ($dir !== null && is_file($dir . $relative)) { + require $dir . $relative; + return; + } } }; spl_autoload_register(ShirabeRpcRuntime::$stubAutoloader, true, true); diff --git a/crates/shirabe-php-rpc/src/lib.rs b/crates/shirabe-php-rpc/src/lib.rs index 34c3961d..d818b364 100644 --- a/crates/shirabe-php-rpc/src/lib.rs +++ b/crates/shirabe-php-rpc/src/lib.rs @@ -1035,6 +1035,13 @@ const RUNTIME_FILES: &[(&str, &str)] = &[ ), ]; +/// Guard classes made autoloadable inside the worker, behind the stubs: they shadow the real +/// Composer class of every Rust-owned FQCN no stub proxies, so the worker cannot fall through to +/// the real implementation and run an instance the Rust side never sees. Generated by +/// `scripts/plugin-stub-generator/generate-stubs`; too many to spell out here, so `build.rs` +/// builds the list from the directory itself. +const GUARD_FILES: &[(&str, &str)] = include!(concat!(env!("OUT_DIR"), "/guard-files.rs")); + struct Worker { stream: UnixStream, // Also queried for its exit status when a socket read/write fails, to tell a dead worker @@ -1106,9 +1113,15 @@ fn spawn_worker() -> anyhow::Result { std::fs::write(&script_path, GLUE_SCRIPT)?; let stubs_dir = tempdir.path().join("stubs"); - for (relative_path, contents) in STUB_FILES.iter().chain(RUNTIME_FILES) { - let path = stubs_dir.join(relative_path); - std::fs::create_dir_all(path.parent().expect("stub paths have a parent"))?; + let guards_dir = tempdir.path().join("guards"); + let files = STUB_FILES + .iter() + .chain(RUNTIME_FILES) + .map(|entry| (&stubs_dir, entry)) + .chain(GUARD_FILES.iter().map(|entry| (&guards_dir, entry))); + for (dir, (relative_path, contents)) in files { + let path = dir.join(relative_path); + std::fs::create_dir_all(path.parent().expect("generated class paths have a parent"))?; std::fs::write(&path, contents)?; } @@ -1141,7 +1154,8 @@ fn spawn_worker() -> anyhow::Result { command .arg(&script_path) .arg(WORKER_SOCKET_FD.to_string()) - .arg(&stubs_dir); + .arg(&stubs_dir) + .arg(&guards_dir); // SAFETY: the closure only calls async-signal-safe syscalls, as required between fork and // exec. It owns the child end, so the descriptor stays alive until the exec happens. unsafe { diff --git a/crates/shirabe-php-rpc/tests/generated_stubs.rs b/crates/shirabe-php-rpc/tests/generated_stubs.rs index 0980745e..5648b44c 100644 --- a/crates/shirabe-php-rpc/tests/generated_stubs.rs +++ b/crates/shirabe-php-rpc/tests/generated_stubs.rs @@ -1,6 +1,6 @@ -//! Freshness check for the generated proxy stubs: `generate-stubs --check` verifies that the -//! committed stub files and the `STUB_FILES` list in `lib.rs` match what the generator emits -//! from the current Composer checkout and classifier report. +//! Freshness check for the generated proxy stubs and guard classes: `generate-stubs --check` +//! verifies that the committed stub and guard files, and the `STUB_FILES` list in `lib.rs`, match +//! what the generator emits from the current Composer checkout and classifier report. //! //! The generator needs a PHP interpreter, its composer vendor directory and the classifier //! report; when any of those is missing the test returns early, following the non-mock test diff --git a/crates/shirabe/src/installer.rs b/crates/shirabe/src/installer.rs index 9b4a84e8..ee60f59f 100644 --- a/crates/shirabe/src/installer.rs +++ b/crates/shirabe/src/installer.rs @@ -273,7 +273,7 @@ impl Installer { self.dev_mode, vec![], IndexMap::new(), - ); + )?; } self.download_manager @@ -469,7 +469,7 @@ impl Installer { self.dev_mode, vec![], IndexMap::new(), - ); + )?; } let audit_config = self.get_audit_config()?.clone(); diff --git a/crates/shirabe/tests/plugin/e2e_extension_installer_test.rs b/crates/shirabe/tests/plugin/e2e_extension_installer_test.rs index 9a95888c..0465757a 100644 --- a/crates/shirabe/tests/plugin/e2e_extension_installer_test.rs +++ b/crates/shirabe/tests/plugin/e2e_extension_installer_test.rs @@ -117,6 +117,10 @@ fn run_install(work: &Path, program: &str, args: &[&str]) -> InstallRun { } } +// TODO(plugin): the plugin's post-install listener does `new Composer\Util\Filesystem()`, and +// the guard class the worker loads for that FQCN raises an explicit error: the Rust side owns +// Filesystem and has no proxy for plugin-constructed instances of it. +#[ignore = "Filesystem is Rust-owned and has no proxy the plugin can construct; see the TODO(plugin) above"] #[test] fn test_extension_installer_install_matches_upstream_composer() { if !php_runtime_available() { diff --git a/crates/shirabe/tests/plugin/e2e_installers_test.rs b/crates/shirabe/tests/plugin/e2e_installers_test.rs index 67c51b5c..9d8693fb 100644 --- a/crates/shirabe/tests/plugin/e2e_installers_test.rs +++ b/crates/shirabe/tests/plugin/e2e_installers_test.rs @@ -99,6 +99,11 @@ fn edit(path: &Path, from: &str, to: &str) { std::fs::write(path, text.replace(from, to)).unwrap(); } +// TODO(plugin): the plugin's Installer extends Composer\Installer\LibraryInstaller, whose +// constructor the guard class the worker loads rejects with an explicit error: the Rust side owns +// LibraryInstaller and has no proxy a plugin can subclass. The same applies to the two tests +// below. +#[ignore = "LibraryInstaller is Rust-owned and has no proxy a plugin can subclass; see the TODO(plugin) above"] #[test] fn test_composer_installers_matches_upstream_composer() { if !php_runtime_available() || !plugin_fetched() { @@ -139,6 +144,7 @@ fn test_composer_installers_matches_upstream_composer() { /// The rest of the installer contract: `update` reinstalls a package in place, a second `install` /// runs over an already-installed tree, and `remove` reaches the plugin's own `uninstall()` /// override — the one that chains onto the promise `LibraryInstaller::uninstall` returns. +#[ignore = "LibraryInstaller is Rust-owned and has no proxy a plugin can subclass; see the TODO(plugin) above"] #[test] fn test_composer_installers_update_and_remove_match_upstream_composer() { if !php_runtime_available() || !plugin_fetched() { @@ -203,6 +209,7 @@ fn test_composer_installers_update_and_remove_match_upstream_composer() { /// The plugin's configuration surface: `installer-paths` in the root package's extra (both the /// `type:` and the package-name matcher, with `{$name}` templating) and `installer-name` in the /// installed package's own extra. Both are read back through the package proxy. +#[ignore = "LibraryInstaller is Rust-owned and has no proxy a plugin can subclass; see the TODO(plugin) above"] #[test] fn test_composer_installers_custom_paths_match_upstream_composer() { if !php_runtime_available() || !plugin_fetched() { diff --git a/crates/shirabe/tests/plugin/e2e_normalize_test.rs b/crates/shirabe/tests/plugin/e2e_normalize_test.rs index ccfdb596..9fb49bab 100644 --- a/crates/shirabe/tests/plugin/e2e_normalize_test.rs +++ b/crates/shirabe/tests/plugin/e2e_normalize_test.rs @@ -1,7 +1,7 @@ //! ergebnis/composer-normalize E2E compatibility check: upstream Composer and Shirabe each -//! install the pinned plugin (with its real dependency tree) and the `list`/`help` renderings -//! of its command are compared; the execution comparison is present but ignored until the -//! worker can construct a second native Composer instance. +//! install the pinned plugin (with its real dependency tree); the `list`/`help` renderings of its +//! command are compared, as is running the command. Both comparisons are ignored, each for the +//! reason recorded on it. //! //! Prerequisites: the PHP runtime, the Composer checkout, and the pinned packages in //! `fixtures/e2e-normalize/ext/` — run `fixtures/e2e-normalize/fetch` once to populate it. @@ -60,6 +60,10 @@ fn normalize_lines(stdout: &str) -> Vec<&str> { .collect() } +// TODO(plugin): NormalizePlugin::getCommands() does `new Composer\Factory()`, and the guard +// class the worker loads for that FQCN raises an explicit error: Factory has no counterpart the +// worker can run, so the plugin's command never reaches the command list. +#[ignore = "the plugin's command provider constructs Composer\\Factory, which the worker has no counterpart for; see the TODO(plugin) above"] #[test] fn test_normalize_listing_matches_upstream_composer() { if !php_runtime_available() { diff --git a/docs/dev/php-rpc.md b/docs/dev/php-rpc.md index b1c992d7..f91b8e54 100644 --- a/docs/dev/php-rpc.md +++ b/docs/dev/php-rpc.md @@ -185,7 +185,7 @@ allocates the Rust entity behind a `new SomeProxiedClass(...)` written by plugin answers with `[rhandle, epoch]`. Classes whose entity Rust cannot build are an explicit error naming the class. -## Proxy stubs and runtime classes +## Proxy stubs, runtime classes and guards `php/stubs/` holds the proxy stub classes (`Composer\Script\Event`, `Composer\PartialComposer`, `Composer\Composer`, the `Composer\IO\{BaseIO,ConsoleIO,BufferIO,NullIO}` hierarchy, the @@ -215,8 +215,14 @@ by `scripts/plugin-stub-generator/generate-stubs` and must not be edited by hand here) is a faithful in-process port of the real base class and crosses the wire as a P-table entity (`__shirabeRustHandleDescriptor()` returns null in native mode). -Both sets are written into the same autoload directory at worker spawn and resolved with -highest priority, so these FQCNs can never be shadowed by the real implementation; +`php/guards/` holds the guard classes: one per Composer class whose entity lives on the Rust side +and that neither a stub nor a runtime class shadows. A guard keeps the FQCN, the hierarchy and the +constants of the real class, and raises an explicit error from its constructor and every method, +so code running here can never work on a second instance the Rust side never sees. They are +generated by the same tool as the stubs. + +The three sets are written into two autoload directories at worker spawn (guards behind stubs) and +resolved with highest priority, so these FQCNs can never be shadowed by the real implementation; `__shirabe_require` restores that priority after loading code that prepends its own autoloader. Stubs are interned per rhandle (`WeakReference`-based registry) so identity (`===`) holds, and their destructors send `ReleaseRustHandle`. Reviving a stub for an existing entity bypasses its diff --git a/docs/dev/plugin-class-classification.md b/docs/dev/plugin-class-classification.md index c77eca52..7046ea48 100644 --- a/docs/dev/plugin-class-classification.md +++ b/docs/dev/plugin-class-classification.md @@ -42,6 +42,12 @@ exactly one category. | `php-native` | PHP | the real, unmodified PHP source | none — Rust may or may not have its own port for internal use, and that port is free to diverge in shape | | `unsupported` | n/a | nothing; any reference raises an explicit error | none, until explicitly promoted | +A Rust-owned class the child has no artifact for — every `unsupported` one, and +every `rust-proxy`/`rust-snapshot` one whose stub is not written yet — is +shadowed there by a generated *guard* class instead of the real Composer source: +same FQCN, hierarchy and constants, every constructor and method an explicit +error. See `docs/dev/plugin-stub-generation.md`. + #### rust-proxy The living services: `Composer`, `Config`, `RepositoryManager`, diff --git a/docs/dev/plugin-stub-generation.md b/docs/dev/plugin-stub-generation.md index 68dde156..e2b6d848 100644 --- a/docs/dev/plugin-stub-generation.md +++ b/docs/dev/plugin-stub-generation.md @@ -1,4 +1,4 @@ -# Plugin proxy stub generation +# Plugin proxy stub and guard generation ## Purpose @@ -12,13 +12,21 @@ Composer sources keeps every stub signature mechanically faithful to the real class and turns "the stub is missing something the real class has" into a generation failure instead of silent breakage. +The same tool emits the **guard classes** under +`crates/shirabe-php-rpc/php/guards/`, one for every Rust-owned class no stub and +no `php/runtime/` class shadows. Without them the worker's autoloader falls +through to the real Composer implementation, and code running there works on a +second instance the Rust side never sees — silent breakage in place of the +explicit error an unimplemented plugin API is supposed to raise. `build.rs` +embeds the guards, building the list from the directory itself. + ## Running ``` cd scripts/plugin-stub-generator composer install # once; vendor/ is git-ignored -./generate-stubs # rewrites crates/shirabe-php-rpc/php/stubs/ -./generate-stubs --check # verifies committed stubs are fresh; exit 1 otherwise +./generate-stubs # rewrites the stubs/ and guards/ directories +./generate-stubs --check # verifies committed files are fresh; exit 1 otherwise ``` Inputs: @@ -36,10 +44,19 @@ Inputs: interfaces from vendor packages (e.g. `Psr\Log\LoggerInterface`) resolve too. * the classifier report (`scripts/plugin-class-classifier/report.json`, override with `--report=`). Run `scripts/plugin-class-classifier/classify` first; the - report is git-ignored. Every target must be classified `rust-proxy` or - `contract`, and a report with classification violations is rejected. + report is git-ignored. Every stub target must be classified `rust-proxy` or + `contract`, and a report with classification violations is rejected. The guard + set is read from the report alone: every `rust-proxy`, `rust-snapshot` and + `unsupported` class that is not a stub target, a `php/runtime/` class or an + exemption. +* `guard-exemptions.list` — the Rust-owned FQCNs that stay resolvable to the real + Composer class, each with the worker-side mechanism that makes a native + instance correct (the materialized-value codec for `Composer\Package\Link`, + the dual-mode `Composer\EventDispatcher\Event` for + `Composer\Plugin\PreCommandRunEvent`). An entry that needs no guard anyway + fails the run. -The freshness check runs in `cargo test` as +The freshness check for both sets runs in `cargo test` as `crates/shirabe-php-rpc/tests/generated_stubs.rs`; it returns early when PHP, the generator's vendor directory or the classifier report is unavailable. @@ -93,6 +110,27 @@ the generator's vendor directory or the classifier report is unavailable. order, restricted to names the emitted stub references; signatures declared elsewhere (interface files) are re-spelled through that import table. +## What a guard looks like + +A guard reproduces the real class's declaration — `abstract`/`final`, `extends`, +`implements`, the constants and public static properties verbatim — so that +references satisfied by the declaration alone keep working: `instanceof`, +`catch`, `X::class`, `Link::TYPE_REQUIRE`. Everything executable raises +`ShirabeUnsupportedClass::fail()`, which names the class and the member: + +* the constructor (the class's own, or the one it would inherit; a class whose + hierarchy declares none gets a no-argument one, so the implicit constructor + cannot be reached either), +* every public and protected method the guard would otherwise inherit from real + code: its own, its traits', and those of ancestors up to the first one that is + itself guarded or shadowed by a stub. Private methods are reachable only from + the code the guard replaces, and a destructor that throws would fire during + unwinding, so neither is emitted. + +Abstract methods of an abstract guard stay abstract. A method an ancestor +declares `final` cannot be redeclared, so it keeps running the real +implementation; the guard's header names each one. + ## Coverage assertions Generation fails — instead of emitting something quietly wrong — on: @@ -107,7 +145,7 @@ Generation fails — instead of emitting something quietly wrong — on: * a target whose FQCN is also provided by `php/runtime/`. `generate-stubs` (in both modes) additionally fails when a `.php` file exists -under the stubs directory that no target produces, or when `STUB_FILES` / +under the stubs or guards directory that no target produces, or when `STUB_FILES` / `RUNTIME_FILES` in `crates/shirabe-php-rpc/src/lib.rs` does not embed every generated stub / runtime file. It also cross-checks the handoff property table for `Composer\Console\Application` (declared in `generate-stubs` itself) against diff --git a/scripts/plugin-stub-generator/generate-stubs b/scripts/plugin-stub-generator/generate-stubs index 5db83a27..c58e6a92 100755 --- a/scripts/plugin-stub-generator/generate-stubs +++ b/scripts/plugin-stub-generator/generate-stubs @@ -7,6 +7,8 @@ require __DIR__ . '/vendor/autoload.php'; use Shirabe\PluginStubGenerator\GenerationError; use Shirabe\PluginStubGenerator\Generator; +use Shirabe\PluginStubGenerator\GuardGenerator; +use Shirabe\PluginStubGenerator\NamePrinter; use Shirabe\PluginStubGenerator\Project; use Shirabe\PluginStubGenerator\Report; @@ -14,6 +16,7 @@ $repoRoot = dirname(__DIR__, 2); $composerRoot = $repoRoot . '/composer'; $reportPath = $repoRoot . '/scripts/plugin-class-classifier/report.json'; $stubsDir = $repoRoot . '/crates/shirabe-php-rpc/php/stubs'; +$guardsDir = $repoRoot . '/crates/shirabe-php-rpc/php/guards'; $libRs = $repoRoot . '/crates/shirabe-php-rpc/src/lib.rs'; $check = false; @@ -24,21 +27,31 @@ foreach (array_slice($argv, 1) as $arg) { $reportPath = substr($arg, strlen('--report=')); } elseif (str_starts_with($arg, '--stubs-dir=')) { $stubsDir = substr($arg, strlen('--stubs-dir=')); + } elseif (str_starts_with($arg, '--guards-dir=')) { + $guardsDir = substr($arg, strlen('--guards-dir=')); } elseif ($arg === '--check') { $check = true; } else { - fwrite(STDERR, "usage: generate-stubs [--composer-root=DIR] [--report=FILE] [--stubs-dir=DIR] [--check]\n"); + fwrite(STDERR, "usage: generate-stubs [--composer-root=DIR] [--report=FILE] [--stubs-dir=DIR]" + . " [--guards-dir=DIR] [--check]\n"); exit(2); } } -$targets = []; -foreach (file(__DIR__ . '/targets.list', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) { - $line = trim($line); - if ($line !== '' && !str_starts_with($line, '#')) { - $targets[] = $line; +/** @return list */ +$readList = static function (string $path): array { + $entries = []; + foreach (file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) { + $line = trim($line); + if ($line !== '' && !str_starts_with($line, '#')) { + $entries[] = $line; + } } -} + return $entries; +}; + +$targets = $readList(__DIR__ . '/targets.list'); +$exemptions = $readList(__DIR__ . '/guard-exemptions.list'); // Hand-written dual-mode classes (php/runtime/) resolve through the same worker autoloader as // the generated stubs; the generator must know them so they can serve as stub base classes and @@ -61,9 +74,45 @@ if (is_dir($runtimeDir)) { sort($runtimeProvided); sort($runtimeFiles); +$problems = []; +$report = Report::load($reportPath); +$project = new Project($composerRoot); + +// Every class the Rust side owns and neither a stub nor a runtime class shadows gets a guard, so +// that the worker cannot fall through to the real implementation and run a second instance. +// Exempt entries stay real because the worker has a mechanism that makes a native instance +// correct; each one is justified where it is listed. +$exempt = array_flip($exemptions); +$guardTargets = []; +foreach ($report->inCategories(['rust-proxy', 'rust-snapshot', 'unsupported']) as $fqcn) { + if (in_array($fqcn, $targets, true) || in_array($fqcn, $runtimeProvided, true)) { + continue; + } + if (isset($exempt[$fqcn])) { + unset($exempt[$fqcn]); + continue; + } + if ($report->kind($fqcn) !== 'class') { + $problems[] = "$fqcn is a {$report->kind($fqcn)} in a Rust-owned category; a guard can only" + . ' shadow a class'; + continue; + } + $guardTargets[] = $fqcn; +} +foreach (array_keys($exempt) as $fqcn) { + $problems[] = "stale guard exemption in guard-exemptions.list: $fqcn needs no guard"; +} + try { - $generator = new Generator($composerRoot, Report::load($reportPath), $targets, $runtimeProvided); + $generator = new Generator($composerRoot, $report, $targets, $runtimeProvided); $files = $generator->generate(); + $guardGenerator = new GuardGenerator( + $project, + new NamePrinter(), + $guardTargets, + array_merge($targets, $runtimeProvided), + ); + $guardFiles = $guardGenerator->generate(); } catch (GenerationError $e) { foreach ($e->errors as $error) { fwrite(STDERR, "error: $error\n"); @@ -71,8 +120,6 @@ try { exit(1); } -$problems = []; - // Handoff classification of Composer\Console\Application's declared properties. The worker-side // runtime definition (php/runtime/Composer/Console/Application.php) hands off exactly the state // a plugin-visible application exposes; a property the upstream class declares and this table @@ -87,7 +134,7 @@ $applicationPropertyTable = [ 'hasPluginCommands' => 'rust-local', // Rust-side runtime state, never shared 'logo' => 'static-config', // static rendering data, the worker never needs it ]; -$applicationFile = (new Project($composerRoot))->sourceFor('Composer\\Console\\Application'); +$applicationFile = $project->sourceFor('Composer\\Console\\Application'); $declaredProperties = []; foreach ($applicationFile->classLike->getProperties() as $property) { foreach ($property->props as $prop) { @@ -105,14 +152,23 @@ foreach ($applicationPropertyTable as $name => $_) { } } -$iterator = new RecursiveIteratorIterator( - new RecursiveDirectoryIterator($stubsDir, FilesystemIterator::SKIP_DOTS) -); -foreach ($iterator as $entry) { - if ($entry->isFile() && str_ends_with($entry->getFilename(), '.php')) { - $relative = substr($entry->getPathname(), strlen($stubsDir) + 1); - if (!isset($files[$relative])) { - $problems[] = "stale stub not covered by targets.list: $stubsDir/$relative"; +$generatedSets = [ + [$stubsDir, $files, 'targets.list'], + [$guardsDir, $guardFiles, 'the classifier report'], +]; +foreach ($generatedSets as [$dir, $generated, $source]) { + if (!is_dir($dir)) { + continue; + } + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS) + ); + foreach ($iterator as $entry) { + if ($entry->isFile() && str_ends_with($entry->getFilename(), '.php')) { + $relative = substr($entry->getPathname(), strlen($dir) + 1); + if (!isset($generated[$relative])) { + $problems[] = "stale file not covered by $source: $dir/$relative"; + } } } } @@ -131,24 +187,26 @@ foreach ($runtimeFiles as $relative) { } } -if ($check) { - foreach ($files as $relative => $content) { - $current = @file_get_contents("$stubsDir/$relative"); - if ($current === false) { - $problems[] = "missing stub (regenerate): $stubsDir/$relative"; - } elseif ($current !== $content) { - $problems[] = "stale stub (regenerate): $stubsDir/$relative"; +foreach ([[$stubsDir, $files, 'stubs'], [$guardsDir, $guardFiles, 'guards']] as [$dir, $generated, $what]) { + if ($check) { + foreach ($generated as $relative => $content) { + $current = @file_get_contents("$dir/$relative"); + if ($current === false) { + $problems[] = "missing file (regenerate): $dir/$relative"; + } elseif ($current !== $content) { + $problems[] = "stale file (regenerate): $dir/$relative"; + } } + continue; } -} else { - foreach ($files as $relative => $content) { - $path = "$stubsDir/$relative"; + foreach ($generated as $relative => $content) { + $path = "$dir/$relative"; if (!is_dir(dirname($path))) { mkdir(dirname($path), 0777, true); } file_put_contents($path, $content); } - fwrite(STDERR, count($files) . " stubs written to $stubsDir\n"); + fwrite(STDERR, count($generated) . " $what written to $dir\n"); } foreach ($problems as $problem) { diff --git a/scripts/plugin-stub-generator/guard-exemptions.list b/scripts/plugin-stub-generator/guard-exemptions.list new file mode 100644 index 00000000..90adcb20 --- /dev/null +++ b/scripts/plugin-stub-generator/guard-exemptions.list @@ -0,0 +1,14 @@ +# Classes the Rust side owns that the worker still resolves to the real Composer implementation, +# so no guard is emitted for them. An entry belongs here only when the worker has a mechanism that +# makes a natively constructed instance correct; without one the class must be guarded, or code +# running here would silently work on an instance the Rust side never sees. One FQCN per line, +# each with the mechanism that justifies it. + +# The wire codec revives values of this class from the object record serialize() writes for them +# (php/runtime/Shirabe/MaterializedValue.php), so the real declaration has to stay loadable. +Composer\Package\Link + +# Real Composer\Command code running in this worker constructs one (BaseCommand::initialize), and +# the dual-mode Composer\EventDispatcher\Event (php/runtime/) carries a natively constructed event +# to the Rust side as a P-table entity. +Composer\Plugin\PreCommandRunEvent diff --git a/scripts/plugin-stub-generator/src/Generator.php b/scripts/plugin-stub-generator/src/Generator.php index dd62da60..0b8ef037 100644 --- a/scripts/plugin-stub-generator/src/Generator.php +++ b/scripts/plugin-stub-generator/src/Generator.php @@ -159,7 +159,7 @@ final class Generator return []; } $surface = []; - $parentFqcn = $class->extends === null ? null : $this->resolvedName($class->extends); + $parentFqcn = $class->extends === null ? null : SourceFile::resolvedName($class->extends); if ($parentFqcn !== null) { $surface = $this->surfaces[$parentFqcn] ?? $this->surfaceFromRealClass($parentFqcn); } @@ -195,7 +195,7 @@ final class Generator . '; only rust-proxy and contract classes can become proxy stubs'; } - $parentFqcn = $class->extends === null ? null : $this->resolvedName($class->extends); + $parentFqcn = $class->extends === null ? null : SourceFile::resolvedName($class->extends); $isRoot = $parentFqcn === null; if ($parentFqcn !== null && !isset($this->targetSet[$parentFqcn])) { if (isset($this->runtimeSet[$parentFqcn])) { @@ -348,7 +348,7 @@ final class Generator $header = "// Generated by scripts/plugin-stub-generator; do not edit by hand.\n" . "// Proxy stub for $fqcn: the public surface forwards to the Rust-side entity over RPC."; $text = "namespace};\n\n"; - $uses = $this->usedImports($file, $decl . "\n" . $body); + $uses = $file->importsUsedBy($decl . "\n" . $body); if ($uses !== '') { $text .= $uses . "\n\n"; } @@ -446,11 +446,11 @@ final class Generator } $out[] = $file; foreach ($file->classLike->extends as $parent) { - $visit($this->resolvedName($parent)); + $visit(SourceFile::resolvedName($parent)); } }; foreach ($class->implements as $interface) { - $visit($this->resolvedName($interface)); + $visit(SourceFile::resolvedName($interface)); } return $out; } @@ -527,23 +527,4 @@ final class Generator } return $fingerprint; } - - /** The original file's imports, restricted to names the emitted stub actually uses. */ - private function usedImports(SourceFile $file, string $emittedText): string - { - $kept = []; - foreach ($file->aliases as $alias => $fqcn) { - if (preg_match('/(?getAttribute('resolvedName'); - return $resolved instanceof Name ? $resolved->toString() : $name->toString(); - } } diff --git a/scripts/plugin-stub-generator/src/GuardGenerator.php b/scripts/plugin-stub-generator/src/GuardGenerator.php new file mode 100644 index 00000000..e471f1a3 --- /dev/null +++ b/scripts/plugin-stub-generator/src/GuardGenerator.php @@ -0,0 +1,215 @@ + */ + private array $errors = []; + + /** @var array */ + private array $guardSet = []; + + /** @var array */ + private array $providedSet = []; + + /** @var list `Ancestor::member` entries the class being emitted cannot redeclare */ + private array $inheritedFinals = []; + + /** + * @param list $targets FQCNs to emit a guard for + * @param list $provided FQCNs the worker resolves to a proxy stub or a hand-written + * runtime class; a guard never reproduces their members, since + * those declarations already shadow the real class + */ + public function __construct( + private readonly Project $project, + private readonly NamePrinter $printer, + private readonly array $targets, + array $provided, + ) { + foreach ($targets as $fqcn) { + $this->guardSet[$fqcn] = true; + } + foreach ($provided as $fqcn) { + $this->providedSet[$fqcn] = true; + } + } + + /** @return array relative guard path => file content */ + public function generate(): array + { + $files = []; + foreach ($this->targets as $fqcn) { + $files[str_replace('\\', '/', $fqcn) . '.php'] = $this->emitClass($fqcn); + } + if ($this->errors !== []) { + throw new GenerationError($this->errors); + } + return $files; + } + + private function emitClass(string $fqcn): string + { + $file = $this->project->sourceFor($fqcn); + $class = $file->classLike; + if (!$class instanceof Class_) { + $this->errors[] = "$fqcn is not a class"; + return ''; + } + + // Constants and public static properties are compile-time data with no instance behind + // them; they are copied verbatim, visibility included, so that the references a guard is + // meant to keep working (`Link::TYPE_REQUIRE` and friends) resolve to the real values. + $data = []; + foreach ($class->getConstants() as $constant) { + $data[] = $file->verbatim($constant->getStartLine(), $constant->getEndLine()); + } + foreach ($class->getProperties() as $property) { + if ($property->isPublic() && $property->isStatic()) { + $data[] = $file->verbatim($property->getStartLine(), $property->getEndLine()); + } + } + + $methods = []; + $this->inheritedFinals = []; + $closure = $this->methodClosure($fqcn); + if (!isset($closure['__construct'])) { + // Nothing in the hierarchy declares one, so PHP would hand out the implicit + // constructor. The guard declares its own to close that door. + $methods[] = " public function __construct()\n {\n" + . " \\ShirabeUnsupportedClass::fail(self::class, '__construct');\n }"; + } + foreach ($closure as $lowerName => [$method, $declaringFile]) { + if ($method->isPrivate() || $lowerName === '__destruct') { + // A private method is reachable only from the code the guard replaces, and a + // destructor that throws would fire while an exception is already unwinding. + continue; + } + $methods[] = $this->renderMethod($class, $method, $declaringFile); + } + + $decl = ($class->isAbstract() ? 'abstract ' : '') . ($class->isFinal() ? 'final ' : '') + . 'class ' . $class->name?->toString(); + if ($class->extends !== null) { + $decl .= ' extends ' . $this->printer->renderName($class->extends, $file); + } + $interfaces = array_map(fn (Name $n): string => $this->printer->renderName($n, $file), $class->implements); + if ($interfaces !== []) { + $decl .= ' implements ' . implode(', ', $interfaces); + } + + $body = implode("\n\n", array_merge($data === [] ? [] : [implode("\n", $data)], $methods)); + + $header = "// Generated by scripts/plugin-stub-generator; do not edit by hand.\n" + . "// Guard for $fqcn.\n" + . "// The Rust side owns this class and the worker has no proxy for it, so this\n" + . "// declaration shadows the real one: the constants and the hierarchy stay, while\n" + . "// constructing it or calling anything on it raises an explicit error."; + foreach ($this->inheritedFinals as $member) { + $header .= "\n// $member() is final, so it keeps running the real implementation here."; + } + $text = "namespace};\n\n"; + $uses = $file->importsUsedBy($decl . "\n" . $body); + if ($uses !== '') { + $text .= $uses . "\n\n"; + } + return $text . $decl . "\n{\n" . ($body === '' ? '' : $body . "\n") . "}\n"; + } + + /** + * Every method a guard must redeclare: its own (traits included), plus the ones it would + * otherwise inherit from real ancestors. The walk stops at an ancestor that is guarded or + * shadowed by a stub, since that declaration carries the members from there on up. + * + * @return array lowercased name => method and the + * file its signature is written in + */ + private function methodClosure(string $fqcn): array + { + $file = $this->project->sourceFor($fqcn); + $class = $file->classLike; + $methods = []; + foreach ($class->getMethods() as $method) { + $methods[strtolower($method->name->toString())] ??= [$method, $file]; + } + foreach ($class->getTraitUses() as $traitUse) { + foreach ($traitUse->traits as $trait) { + foreach ($this->methodClosure(SourceFile::resolvedName($trait)) as $name => $entry) { + $methods[$name] ??= $entry; + } + } + } + $parent = $class instanceof Class_ && $class->extends !== null + ? SourceFile::resolvedName($class->extends) + : null; + // A PHP builtin ancestor (FilterIterator, ...) has no source file to read; its methods + // work on internal state that only its own constructor sets up, which the guard blocks. + $isInternal = $parent !== null && class_exists($parent, false) + && (new \ReflectionClass($parent))->isInternal(); + if ($parent !== null && !$isInternal + && !isset($this->guardSet[$parent]) && !isset($this->providedSet[$parent])) { + foreach ($this->methodClosure($parent) as $name => $entry) { + if (isset($methods[$name])) { + continue; + } + if ($entry[0]->isFinal()) { + // PHP refuses the redeclaration, so this member keeps running the real + // implementation; emitClass records it in the guard's header. + $this->inheritedFinals[] = $parent . '::' . $entry[0]->name->toString(); + continue; + } + $methods[$name] = $entry; + } + } + return $methods; + } + + private function renderMethod(Class_ $class, ClassMethod $method, SourceFile $target): string + { + $name = $method->name->toString(); + $params = []; + foreach ($method->params as $param) { + $rendered = ''; + if ($param->type !== null) { + $rendered = $this->printer->renderType($param->type, $target) . ' '; + } + // A promoted constructor property declares state the guard has no use for; the + // parameter itself is kept so the signature a caller sees does not change. + $rendered .= ($param->byRef ? '&' : '') . ($param->variadic ? '...' : '') . '$' . $param->var->name; + if ($param->default !== null) { + $rendered .= ' = ' . $this->printer->renderExpr($param->default, $target); + } + $params[] = $rendered; + } + + $abstract = $method->isAbstract() && $class->isAbstract(); + $returnType = $this->printer->renderType($method->returnType, $target); + $signature = ($abstract ? 'abstract ' : '') . ($method->isFinal() ? 'final ' : '') + . ($method->isPublic() ? 'public' : 'protected') + . ($method->isStatic() ? ' static' : '') + . ' function ' . ($method->byRef ? '&' : '') . $name . '(' . implode(', ', $params) . ')' + . ($returnType === '' ? '' : ": $returnType"); + if ($abstract) { + return " $signature;"; + } + return " $signature\n {\n" + . " \\ShirabeUnsupportedClass::fail(self::class, '$name');\n }"; + } + +} diff --git a/scripts/plugin-stub-generator/src/Report.php b/scripts/plugin-stub-generator/src/Report.php index 92e0565e..758a1768 100644 --- a/scripts/plugin-stub-generator/src/Report.php +++ b/scripts/plugin-stub-generator/src/Report.php @@ -7,8 +7,11 @@ namespace Shirabe\PluginStubGenerator; /** The classifier report (scripts/plugin-class-classifier/report.json). */ final class Report { - /** @param array $categories fqcn => category */ - private function __construct(private readonly array $categories) + /** + * @param array $categories fqcn => category + * @param array $kinds fqcn => class / interface / trait / enum + */ + private function __construct(private readonly array $categories, private readonly array $kinds) { } @@ -24,14 +27,38 @@ final class Report throw new GenerationError(["$path records classification violations; fix the classifier lists first"]); } $categories = []; + $kinds = []; foreach ($data['classes'] as $class) { $categories[$class['fqcn']] = $class['category']; + $kinds[$class['fqcn']] = $class['kind']; } - return new self($categories); + return new self($categories, $kinds); } public function category(string $fqcn): ?string { return $this->categories[$fqcn] ?? null; } + + public function kind(string $fqcn): ?string + { + return $this->kinds[$fqcn] ?? null; + } + + /** + * The FQCNs of the given categories, in report order. + * + * @param list $categories + * @return list + */ + public function inCategories(array $categories): array + { + $out = []; + foreach ($this->categories as $fqcn => $category) { + if (in_array($category, $categories, true)) { + $out[] = $fqcn; + } + } + return $out; + } } diff --git a/scripts/plugin-stub-generator/src/SourceFile.php b/scripts/plugin-stub-generator/src/SourceFile.php index 771e83a8..e20bf00f 100644 --- a/scripts/plugin-stub-generator/src/SourceFile.php +++ b/scripts/plugin-stub-generator/src/SourceFile.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Shirabe\PluginStubGenerator; +use PhpParser\Node\Name; use PhpParser\Node\Stmt\ClassLike; use PhpParser\Node\Stmt\GroupUse; use PhpParser\Node\Stmt\Namespace_; @@ -83,4 +84,24 @@ final class SourceFile { return implode("\n", array_slice($this->lines, $startLine - 1, $endLine - $startLine + 1)); } + + /** This file's imports, restricted to the names the emitted text actually uses. */ + public function importsUsedBy(string $emittedText): string + { + $kept = []; + foreach ($this->aliases as $alias => $fqcn) { + if (preg_match('/(?getAttribute('resolvedName'); + return $resolved instanceof Name ? $resolved->toString() : $name->toString(); + } } -- cgit v1.3.1-4-g156e