From ebf4fb0e454ed911f133ef319a534318e3085e71 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sat, 19 Sep 2026 07:09:23 +0200 Subject: [PATCH 1/2] feat(renderer): add renderer:install for verified renderer packages The command installs a renderer package (a directory or .tar.gz carrying renderer-package.json) into a project's Engine renderer boundary. Every payload file is SHA-256 verified before staging; traversal paths, absolute paths and unlisted files are refused; an existing installation is backed up before the atomic replacement and installed/manifest.json is merged, not overwritten. Packages declare their own renderer and platform identities, so future renderer implementations install through this same command without players needing Rust or build tools. --engine targets an Engine checkout directly for development staging; --dry-run verifies without writing. --- README.md | 1 + bin/ichiloto | 2 + composer.json | 1 + src/Commands/RendererInstallCommand.php | 94 ++++++ src/Support/RendererPackageInstaller.php | 395 +++++++++++++++++++++++ tests/renderer-install.php | 229 +++++++++++++ 6 files changed, 722 insertions(+) create mode 100644 src/Commands/RendererInstallCommand.php create mode 100644 src/Support/RendererPackageInstaller.php create mode 100644 tests/renderer-install.php diff --git a/README.md b/README.md index e7c417f..2fa51df 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ The CLI currently ships these commands: - `ichiloto generate:map` for complete Engine 0.5 map scaffolding - `ichiloto generate:actor` for lightweight actor scaffolding - `ichiloto battle` for playing a fight from the arena, or simulating it to balance it +- `ichiloto renderer:install` for installing a verified renderer package into a project's Engine (no Rust or build tools required) ## Getting Started diff --git a/bin/ichiloto b/bin/ichiloto index 803584d..f5900f8 100755 --- a/bin/ichiloto +++ b/bin/ichiloto @@ -19,6 +19,7 @@ use Ichiloto\Console\Commands\GenerateFigletCommand; use Ichiloto\Console\Commands\GenerateMapCommand; use Ichiloto\Console\Commands\NewCommand; use Ichiloto\Console\Commands\PlayCommand; +use Ichiloto\Console\Commands\RendererInstallCommand; use Ichiloto\Console\Commands\UpgradeCommand; use Ichiloto\Console\Commands\ValidateCommand; use Symfony\Component\Console\Application; @@ -38,6 +39,7 @@ function bootstrap(): int new GenerateMapCommand(), new GenerateActorCommand(), new GenerateFigletCommand(), + new RendererInstallCommand(), new UpgradeCommand(), new ValidateCommand() ]); diff --git a/composer.json b/composer.json index 381c4e1..43005d7 100644 --- a/composer.json +++ b/composer.json @@ -44,6 +44,7 @@ "@php tests/portable-composer-dependencies.php", "@php tests/local-sibling-autoload.php", "@php tests/renderer-selection.php", + "@php tests/renderer-install.php", "@php tests/generate-map.php", "@php tests/new-project-save-compatibility.php", "@php tests/legacy-project-upgrade.php", diff --git a/src/Commands/RendererInstallCommand.php b/src/Commands/RendererInstallCommand.php new file mode 100644 index 0000000..3594e69 --- /dev/null +++ b/src/Commands/RendererInstallCommand.php @@ -0,0 +1,94 @@ +addArgument('package', InputArgument::REQUIRED, 'A renderer package: a .tar.gz archive or an extracted package directory.') + ->addOption('directory', 'd', InputOption::VALUE_REQUIRED, 'The project whose installed Engine receives the renderer.') + ->addOption('engine', null, InputOption::VALUE_REQUIRED, 'Install directly into this Engine checkout instead of a project\'s Engine package.') + ->addOption('dry-run', null, InputOption::VALUE_NONE, 'Verify the package and report the installation without writing files.'); + } + + public function execute(InputInterface $input, OutputInterface $output): int + { + $package = (string) $input->getArgument('package'); + $engineOption = $input->getOption('engine'); + $dryRun = (bool) $input->getOption('dry-run'); + + try { + $boundary = is_string($engineOption) && $engineOption !== '' + ? $this->installer->resolveEngineBoundary($engineOption) + : $this->installer->resolveProjectBoundary((string) ($input->getOption('directory') ?? getcwd() ?: '.')); + + $result = $this->installer->install($package, $boundary, $dryRun); + } catch (Throwable $throwable) { + $output->writeln('The renderer package could not be installed: ' . $throwable->getMessage() . ''); + + return Command::FAILURE; + } + + $verb = $result['dryRun'] ? 'Would install' : 'Installed'; + $version = $result['packageVersion'] !== '' ? ' ' . $result['packageVersion'] : ''; + + $output->writeln(sprintf( + '✓ %s renderer %s%s for %s (%d verified files).', + $verb, + $result['renderer'], + $version, + $result['platform'], + $result['fileCount'], + )); + $output->writeln(sprintf(' Boundary: %s', $result['installedDirectory'])); + + if ($result['backupDirectory'] !== null) { + $output->writeln(sprintf( + ' %s backed up to %s', + $result['dryRun'] ? 'The existing installation would be' : 'The existing installation was', + $result['backupDirectory'], + )); + } + + if ($result['platform'] !== $result['hostPlatform']) { + $output->writeln(sprintf( + 'Note: the package targets %s, but this host is %s; the Engine will not select it here.', + $result['platform'], + $result['hostPlatform'], + )); + } + + if ($result['dryRun']) { + $output->writeln('Dry run only; no files were changed.'); + } else { + $output->writeln(sprintf( + 'Launch with ichiloto play --renderer=%s once the Engine registers that renderer.', + $result['renderer'], + )); + } + + return Command::SUCCESS; + } +} diff --git a/src/Support/RendererPackageInstaller.php b/src/Support/RendererPackageInstaller.php new file mode 100644 index 0000000..4003b3c --- /dev/null +++ b/src/Support/RendererPackageInstaller.php @@ -0,0 +1,395 @@ +//). Every payload file is + * SHA-256 verified against the package descriptor before anything is staged. + */ +final readonly class RendererPackageInstaller +{ + private const int DESCRIPTOR_VERSION = 1; + + private const string DESCRIPTOR_FILE = 'renderer-package.json'; + + private const int MANIFEST_VERSION = 1; + + /** + * @return array{ + * renderer: string, + * platform: string, + * hostPlatform: string, + * packageVersion: string, + * executable: string, + * fileCount: int, + * installedDirectory: string, + * backupDirectory: ?string, + * dryRun: bool, + * } + */ + public function install(string $packagePath, string $boundaryDirectory, bool $dryRun = false): array + { + $real = realpath($packagePath); + + if ($real === false) { + throw new RuntimeException(sprintf('Renderer package %s does not exist.', $packagePath)); + } + + $temporary = null; + + if (! is_dir($real)) { + if (! str_ends_with(strtolower($real), '.tar.gz')) { + throw new RuntimeException('A renderer package is a directory or a .tar.gz archive.'); + } + + $temporary = sys_get_temp_dir() . '/ichiloto-renderer-package-' . bin2hex(random_bytes(8)); + $this->ensureDirectory($temporary); + + try { + new PharData($real)->extractTo($temporary, overwrite: true); + } catch (Throwable $error) { + $this->removeTree($temporary); + + throw new RuntimeException('The renderer package archive could not be extracted: ' . $error->getMessage()); + } + } + + try { + return $this->installExtracted($temporary ?? $real, $boundaryDirectory, $dryRun); + } finally { + if ($temporary !== null) { + $this->removeTree($temporary); + } + } + } + + /** + * Resolves the Engine renderer boundary for a project directory: the + * project's installed Engine package. A path-repository symlink resolves + * to the live Engine checkout, which is the intended target there. + */ + public function resolveProjectBoundary(string $projectDirectory): string + { + $engine = $projectDirectory . '/vendor/ichiloto/engine'; + + if (! is_dir($engine)) { + throw new RuntimeException(sprintf( + 'No installed Engine package at %s. Run composer install, or pass --engine with an Engine checkout.', + $engine, + )); + } + + return $this->resolveEngineBoundary($engine); + } + + /** Resolves the renderer boundary inside an Engine checkout or package. */ + public function resolveEngineBoundary(string $engineDirectory): string + { + $real = realpath($engineDirectory); + + if ($real === false || ! is_dir($real . '/resources/renderers')) { + throw new RuntimeException(sprintf( + '%s is not an Engine package: resources/renderers is missing.', + $engineDirectory, + )); + } + + return $real . '/resources/renderers'; + } + + /** Mirrors the Engine resolver's host platform identity. */ + public static function hostPlatform(): string + { + $architecture = match (strtolower(php_uname('m'))) { + 'arm64', 'aarch64' => 'arm64', + 'x86_64', 'amd64' => 'x64', + default => strtolower(php_uname('m')), + }; + + return strtolower(PHP_OS_FAMILY) . '-' . $architecture; + } + + /** @return array{renderer: string, platform: string, packageVersion: string, executable: string, files: array} */ + private function readDescriptor(string $packageRoot): array + { + $descriptorFile = $packageRoot . '/' . self::DESCRIPTOR_FILE; + + if (! is_file($descriptorFile)) { + throw new RuntimeException('The package has no ' . self::DESCRIPTOR_FILE . ' at its root.'); + } + + try { + $descriptor = json_decode((string) file_get_contents($descriptorFile), true, flags: JSON_THROW_ON_ERROR); + } catch (JsonException $error) { + throw new RuntimeException('The package descriptor is not valid JSON: ' . $error->getMessage()); + } + + if (! is_array($descriptor) || ($descriptor['version'] ?? null) !== self::DESCRIPTOR_VERSION) { + throw new RuntimeException('The package descriptor must declare version ' . self::DESCRIPTOR_VERSION . '.'); + } + + $renderer = $descriptor['renderer'] ?? null; + $platform = $descriptor['platform'] ?? null; + $executable = $descriptor['executable'] ?? null; + $files = $descriptor['files'] ?? null; + $packageVersion = $descriptor['packageVersion'] ?? ''; + + if (! is_string($renderer) || preg_match('/^[a-z][a-z0-9-]*$/', $renderer) !== 1) { + throw new RuntimeException('The package descriptor needs a lowercase renderer id.'); + } + + if (! is_string($platform) || preg_match('/^[a-z]+-[a-z0-9_]+$/', $platform) !== 1) { + throw new RuntimeException('The package descriptor needs a platform id such as darwin-arm64 or linux-x64.'); + } + + if (! is_array($files) || $files === []) { + throw new RuntimeException('The package descriptor lists no files.'); + } + + $prefix = $renderer . '/' . $platform . '/'; + + foreach ($files as $path => $hash) { + if (! is_string($path) || ! is_string($hash) || preg_match('/^[0-9a-f]{64}$/', $hash) !== 1) { + throw new RuntimeException('Every package file needs a relative path and a SHA-256 hash.'); + } + + $this->assertSafeRelativePath($path); + + if (! str_starts_with($path, $prefix)) { + throw new RuntimeException(sprintf( + 'Package file %s is outside the package payload root %s.', + $path, + $prefix, + )); + } + } + + if (! is_string($executable) || ! array_key_exists($executable, $files)) { + throw new RuntimeException('The package executable must be one of the listed package files.'); + } + + return [ + 'renderer' => $renderer, + 'platform' => $platform, + 'packageVersion' => is_string($packageVersion) ? $packageVersion : '', + 'executable' => $executable, + 'files' => $files, + ]; + } + + private function assertSafeRelativePath(string $path): void + { + if ($path === '' || str_contains($path, "\0") || str_contains($path, '\\') + || preg_match('~^(?:/|[a-zA-Z]:)|(?:^|/)\.\.(?:/|$)~', $path) === 1) { + throw new RuntimeException(sprintf('Package file path %s is not a safe relative path.', $path)); + } + } + + /** @param array $files */ + private function verifyPayload(string $packageRoot, array $files): void + { + foreach ($files as $path => $hash) { + $absolute = $packageRoot . '/' . $path; + + if (! is_file($absolute)) { + throw new RuntimeException(sprintf('The package is missing listed file %s.', $path)); + } + + if (! hash_equals($hash, (string) hash_file('sha256', $absolute))) { + throw new RuntimeException(sprintf('Package file %s fails SHA-256 verification.', $path)); + } + } + + foreach ($this->walkFiles($packageRoot) as $relative) { + if ($relative === self::DESCRIPTOR_FILE) { + continue; + } + + if (! array_key_exists($relative, $files)) { + throw new RuntimeException(sprintf('The package contains unlisted file %s.', $relative)); + } + } + } + + /** @return array{renderer: string, platform: string, hostPlatform: string, packageVersion: string, executable: string, fileCount: int, installedDirectory: string, backupDirectory: ?string, dryRun: bool} */ + private function installExtracted(string $packageRoot, string $boundaryDirectory, bool $dryRun): array + { + $descriptor = $this->readDescriptor($packageRoot); + $this->verifyPayload($packageRoot, $descriptor['files']); + + if (! is_dir($boundaryDirectory)) { + throw new RuntimeException(sprintf('%s is not an Engine renderer boundary.', $boundaryDirectory)); + } + + $installedDirectory = $boundaryDirectory . '/installed'; + $payloadDirectory = $installedDirectory . '/' . $descriptor['renderer'] . '/' . $descriptor['platform']; + $backupDirectory = is_dir($payloadDirectory) + ? sprintf( + '%s/.backups/%s-%s-%s', + $installedDirectory, + $descriptor['renderer'], + $descriptor['platform'], + gmdate('Ymd\THis\Z'), + ) + : null; + + $result = [ + 'renderer' => $descriptor['renderer'], + 'platform' => $descriptor['platform'], + 'hostPlatform' => self::hostPlatform(), + 'packageVersion' => $descriptor['packageVersion'], + 'executable' => $descriptor['executable'], + 'fileCount' => count($descriptor['files']), + 'installedDirectory' => $installedDirectory, + 'backupDirectory' => $backupDirectory, + 'dryRun' => $dryRun, + ]; + + if ($dryRun) { + return $result; + } + + $staging = $installedDirectory . '/.staging-' . getmypid(); + $this->removeTree($staging); + + try { + foreach ($descriptor['files'] as $path => $hash) { + $this->copyFile($packageRoot . '/' . $path, $staging . '/' . $path); + } + + $stagedExecutable = $staging . '/' . $descriptor['executable']; + + if (! @chmod($stagedExecutable, 0755) || ! is_executable($stagedExecutable)) { + throw new RuntimeException('The staged renderer executable could not be made executable.'); + } + + if ($backupDirectory !== null) { + $this->ensureDirectory(dirname($backupDirectory)); + + if (! @rename($payloadDirectory, $backupDirectory)) { + throw new RuntimeException(sprintf('The existing installation could not be backed up to %s.', $backupDirectory)); + } + } + + $this->ensureDirectory(dirname($payloadDirectory)); + + if (! @rename($staging . '/' . $descriptor['renderer'] . '/' . $descriptor['platform'], $payloadDirectory)) { + if ($backupDirectory !== null) { + @rename($backupDirectory, $payloadDirectory); + } + + throw new RuntimeException('The verified payload could not be moved into the installation directory.'); + } + + $this->writeManifest($installedDirectory, $descriptor['renderer'], $descriptor['platform'], $descriptor['executable']); + } finally { + $this->removeTree($staging); + } + + return $result; + } + + private function writeManifest(string $installedDirectory, string $renderer, string $platform, string $executable): void + { + $manifestFile = $installedDirectory . '/manifest.json'; + $manifest = ['version' => self::MANIFEST_VERSION, 'renderers' => []]; + + if (is_file($manifestFile)) { + try { + $existing = json_decode((string) file_get_contents($manifestFile), true, flags: JSON_THROW_ON_ERROR); + + if (is_array($existing) && ($existing['version'] ?? null) === self::MANIFEST_VERSION + && is_array($existing['renderers'] ?? null)) { + $manifest = $existing; + } + } catch (JsonException) { + // An unreadable manifest is replaced by a valid one below. + } + } + + $manifest['renderers'][$renderer][$platform] = $executable; + ksort($manifest['renderers']); + + $encoded = json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"; + $temporary = $manifestFile . '.tmp-' . getmypid(); + + if (@file_put_contents($temporary, $encoded) === false || ! @rename($temporary, $manifestFile)) { + @unlink($temporary); + + throw new RuntimeException('The renderer manifest could not be written.'); + } + } + + /** @return list */ + private function walkFiles(string $root): array + { + $files = []; + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS), + ); + + foreach ($iterator as $item) { + if ($item->isFile()) { + $files[] = substr($item->getPathname(), strlen($root) + 1); + } + } + + sort($files); + + return $files; + } + + private function copyFile(string $source, string $destination): void + { + $this->ensureDirectory(dirname($destination)); + + if (! @copy($source, $destination)) { + throw new RuntimeException(sprintf('Package file %s could not be staged.', $source)); + } + } + + private function ensureDirectory(string $directory): void + { + if (! is_dir($directory) && ! @mkdir($directory, 0755, true) && ! is_dir($directory)) { + throw new RuntimeException(sprintf('Directory %s could not be created.', $directory)); + } + } + + private function removeTree(string $path): void + { + if (! file_exists($path)) { + return; + } + + if (! is_dir($path) || is_link($path)) { + @unlink($path); + + return; + } + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST, + ); + + foreach ($iterator as $item) { + $item->isDir() && ! $item->isLink() ? @rmdir($item->getPathname()) : @unlink($item->getPathname()); + } + + @rmdir($path); + } +} diff --git a/tests/renderer-install.php b/tests/renderer-install.php new file mode 100644 index 0000000..ba8e487 --- /dev/null +++ b/tests/renderer-install.php @@ -0,0 +1,229 @@ +isDir() && ! $item->isLink() ? @rmdir($item->getPathname()) : @unlink($item->getPathname()); + } + + @rmdir($path); +} + +/** @return array{root: string, descriptorFile: string, executableRelative: string} */ +function writeTestPackage(string $root, string $renderer, string $platform, string $executableBody = "#!/bin/sh\nexit 0\n"): array +{ + removeTestTree($root); + $executableRelative = "{$renderer}/{$platform}/{$renderer}-renderer"; + $extraRelative = "{$renderer}/{$platform}/resources/notes.txt"; + makeDirectory(dirname("{$root}/{$executableRelative}")); + makeDirectory(dirname("{$root}/{$extraRelative}")); + file_put_contents("{$root}/{$executableRelative}", $executableBody); + file_put_contents("{$root}/{$extraRelative}", "payload resource\n"); + + $descriptor = [ + 'version' => 1, + 'renderer' => $renderer, + 'displayName' => strtoupper($renderer), + 'platform' => $platform, + 'packageVersion' => '9.9.9-test', + 'executable' => $executableRelative, + 'files' => [ + $executableRelative => hash_file('sha256', "{$root}/{$executableRelative}"), + $extraRelative => hash_file('sha256', "{$root}/{$extraRelative}"), + ], + ]; + file_put_contents( + "{$root}/renderer-package.json", + json_encode($descriptor, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), + ); + + return [ + 'root' => $root, + 'descriptorFile' => "{$root}/renderer-package.json", + 'executableRelative' => $executableRelative, + ]; +} + +/** @return array */ +function readManifest(string $boundary): array +{ + $manifestFile = "{$boundary}/installed/manifest.json"; + assertRendererInstall(is_file($manifestFile), 'manifest.json exists after installation.'); + $manifest = json_decode((string) file_get_contents($manifestFile), true); + assertRendererInstall(is_array($manifest), 'manifest.json holds a JSON object.'); + + return $manifest; +} + +$workspace = sys_get_temp_dir() . '/ichiloto-renderer-install-test-' . getmypid(); +removeTestTree($workspace); + +try { + $engine = "{$workspace}/engine"; + $boundary = "{$engine}/resources/renderers"; + makeDirectory($boundary); + $installer = new RendererPackageInstaller(); + $hostPlatform = RendererPackageInstaller::hostPlatform(); + + // 1. A directory package installs, sets the manifest and the executable bit. + $package = writeTestPackage("{$workspace}/package", 'testgpu', $hostPlatform); + $result = $installer->install($package['root'], $installer->resolveEngineBoundary($engine)); + assertRendererInstall($result['renderer'] === 'testgpu' && $result['fileCount'] === 2, 'install reports the package identity and file count.'); + $installedExecutable = "{$boundary}/installed/{$package['executableRelative']}"; + assertRendererInstall(is_file($installedExecutable) && is_executable($installedExecutable), 'the installed executable exists and is executable.'); + $manifest = readManifest($boundary); + assertRendererInstall( + ($manifest['version'] ?? null) === 1 + && ($manifest['renderers']['testgpu'][$hostPlatform] ?? null) === $package['executableRelative'], + 'the manifest maps the renderer and platform to the packaged executable.', + ); + + // 2. The Engine resolver accepts the installed layout when it is available. + if (class_exists(Ichiloto\Engine\Rendering\Launch\PackagedRendererExecutableResolver::class)) { + $resolved = new Ichiloto\Engine\Rendering\Launch\PackagedRendererExecutableResolver( + "{$boundary}/installed/manifest.json", + $hostPlatform, + )->resolve('testgpu'); + assertRendererInstall(realpath($resolved) === realpath($installedExecutable), 'the Engine resolver resolves the installed executable.'); + } + + // 3. Reinstalling backs up the previous installation and replaces the payload. + $replacement = writeTestPackage("{$workspace}/package-two", 'testgpu', $hostPlatform, "#!/bin/sh\nexit 7\n"); + $result = $installer->install($replacement['root'], $boundary); + assertRendererInstall($result['backupDirectory'] !== null && is_dir($result['backupDirectory']), 'reinstalling backs up the previous payload.'); + assertRendererInstall( + str_contains((string) file_get_contents($installedExecutable), 'exit 7'), + 'reinstalling replaces the executable payload.', + ); + + // 4. A second renderer joins the manifest without disturbing the first. + $second = writeTestPackage("{$workspace}/package-doria", 'doria', $hostPlatform); + $installer->install($second['root'], $boundary); + $manifest = readManifest($boundary); + assertRendererInstall( + isset($manifest['renderers']['testgpu'][$hostPlatform], $manifest['renderers']['doria'][$hostPlatform]), + 'multiple renderers coexist in one manifest.', + ); + + // 5. A tampered payload is refused before anything is staged. + $tampered = writeTestPackage("{$workspace}/package-tampered", 'tampered', $hostPlatform); + file_put_contents("{$tampered['root']}/{$tampered['executableRelative']}", "#!/bin/sh\nexit 1\n"); + $refused = false; + + try { + $installer->install($tampered['root'], $boundary); + } catch (Throwable $error) { + $refused = str_contains($error->getMessage(), 'SHA-256'); + } + + assertRendererInstall($refused, 'a hash mismatch refuses the installation.'); + assertRendererInstall(! isset(readManifest($boundary)['renderers']['tampered']), 'a refused package never reaches the manifest.'); + + // 6. Unsafe descriptor paths are refused. + $unsafe = writeTestPackage("{$workspace}/package-unsafe", 'unsafe', $hostPlatform); + $descriptor = json_decode((string) file_get_contents($unsafe['descriptorFile']), true); + $descriptor['files']['unsafe/' . $hostPlatform . '/../../escape'] = str_repeat('0', 64); + file_put_contents($unsafe['descriptorFile'], json_encode($descriptor)); + $refused = false; + + try { + $installer->install($unsafe['root'], $boundary); + } catch (Throwable) { + $refused = true; + } + + assertRendererInstall($refused, 'a traversal path in the descriptor refuses the installation.'); + + // 7. An unlisted payload file is refused. + $smuggled = writeTestPackage("{$workspace}/package-smuggled", 'smuggled', $hostPlatform); + file_put_contents("{$smuggled['root']}/smuggled/{$hostPlatform}/extra.bin", 'unlisted'); + $refused = false; + + try { + $installer->install($smuggled['root'], $boundary); + } catch (Throwable $error) { + $refused = str_contains($error->getMessage(), 'unlisted'); + } + + assertRendererInstall($refused, 'an unlisted payload file refuses the installation.'); + + // 8. A dry run verifies and reports without writing. + $dry = writeTestPackage("{$workspace}/package-dry", 'dryrun', $hostPlatform); + $result = $installer->install($dry['root'], $boundary, dryRun: true); + assertRendererInstall($result['dryRun'] === true, 'the dry run reports itself.'); + assertRendererInstall(! is_dir("{$boundary}/installed/dryrun"), 'the dry run writes no payload.'); + assertRendererInstall(! isset(readManifest($boundary)['renderers']['dryrun']), 'the dry run writes no manifest entry.'); + + // 9. A .tar.gz archive package installs identically. + if (class_exists(PharData::class)) { + $archiveSource = writeTestPackage("{$workspace}/package-archive", 'archived', $hostPlatform); + $tarFile = "{$workspace}/archived.tar"; + $archive = new PharData($tarFile); + $archive->buildFromDirectory($archiveSource['root']); + $archive->compress(Phar::GZ); + unset($archive); + Phar::unlinkArchive($tarFile); + $installer->install("{$tarFile}.gz", $boundary); + assertRendererInstall( + is_executable("{$boundary}/installed/{$archiveSource['executableRelative']}"), + 'a .tar.gz package installs its executable.', + ); + } + + // 10. A project directory resolves to its installed Engine package. + $project = "{$workspace}/project"; + makeDirectory("{$project}/vendor/ichiloto"); + symlink($engine, "{$project}/vendor/ichiloto/engine"); + assertRendererInstall( + realpath($installer->resolveProjectBoundary($project)) === realpath($boundary), + 'a project resolves to its installed Engine renderer boundary.', + ); + + echo "renderer-install: all checks passed.\n"; +} finally { + removeTestTree($workspace); +} From 771ebbd9d672c8e20bac56863aeaab36257f8dd6 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Tue, 22 Sep 2026 20:40:47 +0200 Subject: [PATCH 2/2] fix(renderer): verify staging and preserve installation state --- src/Support/RendererPackageInstaller.php | 74 +++++++++++++++++++----- tests/renderer-install.php | 62 ++++++++++++++++++++ 2 files changed, 122 insertions(+), 14 deletions(-) diff --git a/src/Support/RendererPackageInstaller.php b/src/Support/RendererPackageInstaller.php index 4003b3c..1b9f697 100644 --- a/src/Support/RendererPackageInstaller.php +++ b/src/Support/RendererPackageInstaller.php @@ -236,14 +236,17 @@ private function installExtracted(string $packageRoot, string $boundaryDirectory } $installedDirectory = $boundaryDirectory . '/installed'; + // Refuse incompatible state before staging or replacing any payload. + $this->readInstalledManifest($installedDirectory); $payloadDirectory = $installedDirectory . '/' . $descriptor['renderer'] . '/' . $descriptor['platform']; $backupDirectory = is_dir($payloadDirectory) ? sprintf( - '%s/.backups/%s-%s-%s', + '%s/.backups/%s-%s-%s-%s', $installedDirectory, $descriptor['renderer'], $descriptor['platform'], gmdate('Ymd\THis\Z'), + bin2hex(random_bytes(8)), ) : null; @@ -268,7 +271,7 @@ private function installExtracted(string $packageRoot, string $boundaryDirectory try { foreach ($descriptor['files'] as $path => $hash) { - $this->copyFile($packageRoot . '/' . $path, $staging . '/' . $path); + $this->copyFile($packageRoot . '/' . $path, $staging . '/' . $path, $hash); } $stagedExecutable = $staging . '/' . $descriptor['executable']; @@ -295,7 +298,17 @@ private function installExtracted(string $packageRoot, string $boundaryDirectory throw new RuntimeException('The verified payload could not be moved into the installation directory.'); } - $this->writeManifest($installedDirectory, $descriptor['renderer'], $descriptor['platform'], $descriptor['executable']); + try { + $this->writeManifest($installedDirectory, $descriptor['renderer'], $descriptor['platform'], $descriptor['executable']); + } catch (Throwable $error) { + $this->removeTree($payloadDirectory); + + if ($backupDirectory !== null && ! @rename($backupDirectory, $payloadDirectory)) { + throw new RuntimeException('The manifest update failed; the previous payload remains at ' . $backupDirectory, previous: $error); + } + + throw $error; + } } finally { $this->removeTree($staging); } @@ -303,24 +316,53 @@ private function installExtracted(string $packageRoot, string $boundaryDirectory return $result; } - private function writeManifest(string $installedDirectory, string $renderer, string $platform, string $executable): void + /** @return array{version: int, renderers: array>} */ + private function readInstalledManifest(string $installedDirectory): array { $manifestFile = $installedDirectory . '/manifest.json'; - $manifest = ['version' => self::MANIFEST_VERSION, 'renderers' => []]; - if (is_file($manifestFile)) { - try { - $existing = json_decode((string) file_get_contents($manifestFile), true, flags: JSON_THROW_ON_ERROR); + if (! file_exists($manifestFile) && ! is_link($manifestFile)) { + return ['version' => self::MANIFEST_VERSION, 'renderers' => []]; + } + + $contents = is_file($manifestFile) ? @file_get_contents($manifestFile) : false; + + if ($contents === false) { + throw new RuntimeException('The existing renderer manifest cannot be read; installation was refused.'); + } + + try { + $manifest = json_decode($contents, true, flags: JSON_THROW_ON_ERROR); + } catch (JsonException $error) { + throw new RuntimeException('The existing renderer manifest is not valid JSON; installation was refused.', previous: $error); + } + + if (! is_array($manifest) || ($manifest['version'] ?? null) !== self::MANIFEST_VERSION + || ! is_array($manifest['renderers'] ?? null)) { + throw new RuntimeException('The existing renderer manifest has an unsupported schema; installation was refused.'); + } - if (is_array($existing) && ($existing['version'] ?? null) === self::MANIFEST_VERSION - && is_array($existing['renderers'] ?? null)) { - $manifest = $existing; + foreach ($manifest['renderers'] as $renderer => $platforms) { + if (! is_string($renderer) || ! is_array($platforms)) { + throw new RuntimeException('The existing renderer manifest has invalid registrations; installation was refused.'); + } + + foreach ($platforms as $platform => $executable) { + if (! is_string($platform) || ! is_string($executable)) { + throw new RuntimeException('The existing renderer manifest has invalid registrations; installation was refused.'); } - } catch (JsonException) { - // An unreadable manifest is replaced by a valid one below. + + $this->assertSafeRelativePath($executable); } } + return $manifest; + } + + private function writeManifest(string $installedDirectory, string $renderer, string $platform, string $executable): void + { + $manifestFile = $installedDirectory . '/manifest.json'; + $manifest = $this->readInstalledManifest($installedDirectory); $manifest['renderers'][$renderer][$platform] = $executable; ksort($manifest['renderers']); @@ -353,13 +395,17 @@ private function walkFiles(string $root): array return $files; } - private function copyFile(string $source, string $destination): void + private function copyFile(string $source, string $destination, string $hash): void { $this->ensureDirectory(dirname($destination)); if (! @copy($source, $destination)) { throw new RuntimeException(sprintf('Package file %s could not be staged.', $source)); } + + if (! hash_equals($hash, (string) hash_file('sha256', $destination))) { + throw new RuntimeException(sprintf('Staged package file %s fails SHA-256 verification.', $source)); + } } private function ensureDirectory(string $directory): void diff --git a/tests/renderer-install.php b/tests/renderer-install.php index ba8e487..838b3b9 100644 --- a/tests/renderer-install.php +++ b/tests/renderer-install.php @@ -140,6 +140,17 @@ function readManifest(string $boundary): array 'reinstalling replaces the executable payload.', ); + // Rapid reinstalls retain every backup, even within the same UTC second. + $backupDirectories = [$result['backupDirectory']]; + for ($repeat = 0; $repeat < 4; $repeat++) { + $reinstalled = $installer->install($replacement['root'], $boundary); + $backupDirectories[] = $reinstalled['backupDirectory']; + } + assertRendererInstall(count(array_unique($backupDirectories)) === 5, 'rapid reinstalls use distinct backup directories.'); + foreach ($backupDirectories as $backupDirectory) { + assertRendererInstall(is_dir($backupDirectory), 'each previous payload remains recoverable.'); + } + // 4. A second renderer joins the manifest without disturbing the first. $second = writeTestPackage("{$workspace}/package-doria", 'doria', $hostPlatform); $installer->install($second['root'], $boundary); @@ -163,6 +174,25 @@ function readManifest(string $boundary): array assertRendererInstall($refused, 'a hash mismatch refuses the installation.'); assertRendererInstall(! isset(readManifest($boundary)['renderers']['tampered']), 'a refused package never reaches the manifest.'); + // Verify the staging boundary rejects bytes changed after source verification. + $racing = writeTestPackage("{$workspace}/package-racing", 'racing', $hostPlatform); + $racingDescriptor = json_decode((string) file_get_contents($racing['descriptorFile']), true); + new ReflectionMethod($installer, 'verifyPayload')->invoke($installer, $racing['root'], $racingDescriptor['files']); + $racingSource = "{$racing['root']}/{$racing['executableRelative']}"; + file_put_contents($racingSource, 'changed after verification'); + $refused = false; + try { + new ReflectionMethod($installer, 'copyFile')->invoke( + $installer, + $racingSource, + "{$workspace}/staged-racing", + $racingDescriptor['files'][$racing['executableRelative']], + ); + } catch (RuntimeException $error) { + $refused = str_contains($error->getMessage(), 'Staged package file') && str_contains($error->getMessage(), 'SHA-256'); + } + assertRendererInstall($refused, 'staged bytes are rehashed against the descriptor, not trusted from the first verification.'); + // 6. Unsafe descriptor paths are refused. $unsafe = writeTestPackage("{$workspace}/package-unsafe", 'unsafe', $hostPlatform); $descriptor = json_decode((string) file_get_contents($unsafe['descriptorFile']), true); @@ -223,6 +253,38 @@ function readManifest(string $boundary): array 'a project resolves to its installed Engine renderer boundary.', ); + // Unmergeable manifests must preserve both registrations and existing bytes. + $manifestFile = "{$boundary}/installed/manifest.json"; + $validManifest = file_get_contents($manifestFile); + $originalPayload = file_get_contents($installedExecutable); + foreach (['{', '{"version":2,"renderers":{"future":{}}}', '{"version":1}', + '{"version":1,"renderers":{"testgpu":"invalid"}}', + '{"version":1,"renderers":{"testgpu":{"linux-x64":false}}}'] as $invalidManifest) { + file_put_contents($manifestFile, $invalidManifest); + foreach ([true, false] as $dryRun) { + $refused = false; + try { + $installer->install($package['root'], $boundary, $dryRun); + } catch (RuntimeException $error) { + $refused = str_contains($error->getMessage(), 'existing renderer manifest'); + } + assertRendererInstall($refused, 'an unmergeable manifest refuses real and dry-run installations.'); + assertRendererInstall(file_get_contents($manifestFile) === $invalidManifest, 'a refused manifest stays byte-identical.'); + assertRendererInstall(file_get_contents($installedExecutable) === $originalPayload, 'refusal happens before payload replacement.'); + } + } + unlink($manifestFile); + mkdir($manifestFile); + $refused = false; + try { + $installer->install($package['root'], $boundary); + } catch (RuntimeException $error) { + $refused = str_contains($error->getMessage(), 'cannot be read'); + } + assertRendererInstall($refused && is_dir($manifestFile), 'an unreadable non-file manifest is preserved and refused.'); + rmdir($manifestFile); + file_put_contents($manifestFile, $validManifest); + echo "renderer-install: all checks passed.\n"; } finally { removeTestTree($workspace);