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..1b9f697
--- /dev/null
+++ b/src/Support/RendererPackageInstaller.php
@@ -0,0 +1,441 @@
+//). 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';
+ // 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',
+ $installedDirectory,
+ $descriptor['renderer'],
+ $descriptor['platform'],
+ gmdate('Ymd\THis\Z'),
+ bin2hex(random_bytes(8)),
+ )
+ : 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, $hash);
+ }
+
+ $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.');
+ }
+
+ 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);
+ }
+
+ return $result;
+ }
+
+ /** @return array{version: int, renderers: array>} */
+ private function readInstalledManifest(string $installedDirectory): array
+ {
+ $manifestFile = $installedDirectory . '/manifest.json';
+
+ 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.');
+ }
+
+ 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.');
+ }
+
+ $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']);
+
+ $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, 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
+ {
+ 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..838b3b9
--- /dev/null
+++ b/tests/renderer-install.php
@@ -0,0 +1,291 @@
+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.',
+ );
+
+ // 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);
+ $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.');
+
+ // 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);
+ $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.',
+ );
+
+ // 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);
+}