From 8e43927f5c4f41b51996ec546416abfa2a410cdf Mon Sep 17 00:00:00 2001 From: MiddleSokilAI Date: Mon, 2 Nov 2026 21:18:47 +0200 Subject: [PATCH] fix(installer): stop CLI input loops on EOF --- core/tests/Fixtures/cli-install-input.php | 42 +++++++ core/tests/standalone/CliInstallInputTest.php | 105 ++++++++++++++++++ install/cli-install.php | 14 +++ 3 files changed, 161 insertions(+) create mode 100644 core/tests/Fixtures/cli-install-input.php create mode 100644 core/tests/standalone/CliInstallInputTest.php diff --git a/core/tests/Fixtures/cli-install-input.php b/core/tests/Fixtures/cli-install-input.php new file mode 100644 index 0000000000..c3c56aff02 --- /dev/null +++ b/core/tests/Fixtures/cli-install-input.php @@ -0,0 +1,42 @@ +ask('Database name?', 'fallback'); + break; + case 'choice': + echo 'ANSWER:' . $installer->choice('Install or update?', ['Install', 'Update']); + break; + case 'prefix-recovery': + $installer->dbh = new PDO('sqlite::memory:'); + $installer->dbh->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $installer->dbh->exec('CREATE TABLE evo_site_content (id INTEGER)'); + $installer->tablePrefix = 'evo_'; + $installer->checkIssetTablePrefix(); + echo 'ANSWER:' . $installer->tablePrefix; + break; + default: + throw new RuntimeException('Unknown test mode'); + } +} catch (RuntimeException $exception) { + error($exception->getMessage()); + exit(1); +} diff --git a/core/tests/standalone/CliInstallInputTest.php b/core/tests/standalone/CliInstallInputTest.php new file mode 100644 index 0000000000..fef7e5e08d --- /dev/null +++ b/core/tests/standalone/CliInstallInputTest.php @@ -0,0 +1,105 @@ + $arguments PHP script path followed by its arguments. + * @return array{int, string} Exit status and combined output. + */ +$run = static function (array $arguments, string $input = '', bool $nullInput = false) use ($temp, $root): array { + $outputFile = $temp . '/output'; + $descriptors = [ + 0 => $nullInput ? ['file', PHP_OS_FAMILY === 'Windows' ? 'NUL' : '/dev/null', 'r'] : ['pipe', 'r'], + 1 => ['file', $outputFile, 'w'], + 2 => ['file', $outputFile, 'a'], + ]; + $process = proc_open([PHP_BINARY, ...$arguments], $descriptors, $pipes, $root . '/install'); + if (!is_resource($process)) { + throw new RuntimeException('Could not start PHP input probe'); + } + if (!$nullInput) { + if ($input !== '') { + fwrite($pipes[0], $input); + } + fclose($pipes[0]); + } + $deadline = microtime(true) + 5; + do { + $status = proc_get_status($process); + if (!$status['running']) { + $exitCode = $status['exitcode']; + proc_close($process); + return [$exitCode, (string) file_get_contents($outputFile)]; + } + usleep(10000); + } while (microtime(true) < $deadline); + + proc_terminate($process, 9); + proc_close($process); + throw new RuntimeException('CLI input probe exceeded five seconds: ' . implode(' ', $arguments)); +}; + +$check = static function (array $result, int $exitCode, string $message): void { + if ($result[0] !== $exitCode || !str_contains($result[1], $message)) { + throw new RuntimeException('Unexpected CLI result: ' . var_export($result, true)); + } + if (preg_match('/\b(Fatal error|Warning|Notice|Deprecated):/', $result[1])) { + throw new RuntimeException('PHP diagnostic in CLI output: ' . $result[1]); + } +}; + +try { + foreach ([false, true] as $nullInput) { + // Exercise the real entrypoint and its RuntimeException -> exit 1 handling. + $check($run([$root . '/install/cli-install.php'], '', $nullInput), 1, + 'End of input while waiting for: Please choose your variant of install:'); + $check($run([$root . '/install/cli-install.php', '--typeInstall=1', '--databaseType=sqlite'], '', $nullInput), 1, + 'End of input while waiting for: Please enter database:'); + // EOF must not silently accept either kind of default. + $check($run([$fixture, 'ask'], '', $nullInput), 1, 'End of input while waiting for: Database name?'); + $check($run([$fixture, 'choice'], '', $nullInput), 1, 'End of input while waiting for: Install or update?'); + } + + $check($run([$fixture, 'ask'], "\n"), 0, 'ANSWER:fallback'); + $check($run([$fixture, 'ask'], " custom \n"), 0, 'ANSWER:custom'); + $check($run([$fixture, 'choice'], "\n"), 0, 'ANSWER:Install'); + $check($run([$fixture, 'choice'], "invalid\n2\n"), 0, 'ANSWER:Update'); + $check($run([$fixture, 'choice'], "invalid\n"), 1, 'End of input while waiting for: Install or update?'); + + // Real SQLite prefix collision with all CLI arguments supplied. The failure + // must leave the existing data and installer intact, even with removeInstall=y. + $database = new PDO('sqlite:' . $temp . '/database/existing.sqlite'); + $database->exec('CREATE TABLE evo_site_content (id INTEGER)'); + $database->exec('INSERT INTO evo_site_content VALUES (42)'); + foreach ([false, true] as $nullInput) { + $result = $run([$fixture, 'prefix-cli', $temp], '', $nullInput); + $check($result, 1, 'Table prefix already exists'); + $check($result, 1, 'End of input while waiting for: Please enter table_prefix:'); + if ((int) $database->query('SELECT id FROM evo_site_content')->fetchColumn() !== 42 + || !is_file($root . '/install/cli-install.php') + || is_file($temp . '/config/database/connections/default.php')) { + throw new RuntimeException('Prefix conflict modified data, wrote configuration or removed the installer'); + } + } + $check($run([$fixture, 'prefix-recovery'], "other_\n"), 0, 'ANSWER:other_'); + $check($run([$fixture, 'prefix-recovery'], "\n"), 1, 'End of input while waiting for: Please enter table_prefix:'); + echo "PASS: CLI EOF exits 1, defaults require a line, piped answers work, prefix conflict preserves data.\n"; +} finally { + $database = null; + foreach (glob($temp . '/database/*') ?: [] as $file) { + unlink($file); + } + @unlink($temp . '/output'); + rmdir($temp . '/database'); + rmdir($temp); +} diff --git a/install/cli-install.php b/install/cli-install.php index 8ef0171fe7..3561c60fa2 100644 --- a/install/cli-install.php +++ b/install/cli-install.php @@ -20,6 +20,8 @@ * * The sqlite database is created as core/database/<--database>.sqlite * Use --skipComposer=y to keep the already installed dependencies untouched. + * Missing or invalid values can prompt for input. EOF at a prompt exits with code 1; + * an empty input line, rather than EOF, is required to accept a prompt's default. * An update restores the packages of core/custom/composer.json unless --skipComposer=y is given, * at the versions the last restore noted; --updateCustomPackages=y lets Composer choose others. * When they cannot be restored, the update ends incomplete: exit code 1, install/ kept. @@ -49,6 +51,10 @@ function runCliInstall(array $argv): int exit(runCliInstall($argv)); } +/** + * Run CLI installation or update steps, prompting only for missing or invalid input. + * Input prompts throw on EOF so unattended runs terminate instead of retrying forever. + */ class InstallEvo { /** @@ -159,6 +165,7 @@ public function read_line($message, $message2 = "") * @param array $options ['Install', 'Update', …] * @param int $default Default option index * @return string Selected element of the $options array + * @throws RuntimeException When stdin reaches EOF before a selection is supplied. */ public function choice(string $question, array $options, int $default = 0): string { @@ -191,6 +198,9 @@ public function choice(string $question, array $options, int $default = 0): stri $max = count($options); do { $answer = readline("Choose [1-{$max}]: "); + if ($answer === false) { + throw new RuntimeException('End of input while waiting for: ' . $question . ' Supply the required CLI arguments or provide stdin.'); + } if ($answer === '' && isset($options[$default])) { return $options[$default]; } @@ -206,6 +216,7 @@ public function choice(string $question, array $options, int $default = 0): stri * @param string $question Question text («Please enter database server») * @param string|null $default Default value if Enter is pressed * @return string + * @throws RuntimeException When stdin reaches EOF; defaults apply only to an empty line. */ public function ask(string $question, ?string $default = null): string { @@ -217,6 +228,9 @@ public function ask(string $question, ?string $default = null): string echo PHP_EOL . $yellow . $question . $suffix . $reset . PHP_EOL; $answer = readline('➤ '); + if ($answer === false) { + throw new RuntimeException('End of input while waiting for: ' . $question . ' Supply the required CLI arguments or provide stdin.'); + } return $answer !== '' ? trim($answer) : (string) $default; }