Skip to content

Commit 2aed5a6

Browse files
committed
fix(installer): fatal error when installer can't write a config file
1 parent 851c705 commit 2aed5a6

34 files changed

Lines changed: 432 additions & 67 deletions

core/tests/Unit/Install/CliInstallTest.php

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,39 @@
44

55
use Tests\TestCase;
66

7+
final class PartialInstallConfigWriteStream
8+
{
9+
public mixed $context;
10+
11+
private int $writeCount = 0;
12+
13+
public function stream_open(): bool
14+
{
15+
return true;
16+
}
17+
18+
public function stream_write(string $data): int
19+
{
20+
$this->writeCount++;
21+
22+
return $this->writeCount === 1 ? min(2, strlen($data)) : 0;
23+
}
24+
25+
public function stream_flush(): bool
26+
{
27+
return true;
28+
}
29+
30+
public function stream_close(): void
31+
{
32+
}
33+
34+
public function url_stat(): false
35+
{
36+
return false;
37+
}
38+
}
39+
740
require_once dirname(__DIR__, 4) . '/install/cli-install.php';
841

942
final class CliInstallTest extends TestCase
@@ -64,4 +97,68 @@ public function getAttribute($attribute): string
6497
self::assertStringNotContainsString('[+database_name+]', $config);
6598
self::assertStringContainsString("'username' => env('DB_USERNAME', 'db_user')", $config);
6699
}
100+
101+
public function testConfigWriterReturnsFalseWhenTheParentDirectoryDoesNotExist(): void
102+
{
103+
$path = sys_get_temp_dir() . '/evo-missing-' . uniqid('', true) . '/default.php';
104+
105+
self::assertFalse(hasInstallConfigPermissions($path));
106+
self::assertFalse(writeInstallConfigFile($path, '<?php return [];'));
107+
self::assertFileDoesNotExist($path);
108+
}
109+
110+
public function testConfigWriterWritesTheCompleteContents(): void
111+
{
112+
$path = tempnam(sys_get_temp_dir(), 'evo-config-');
113+
self::assertIsString($path);
114+
$contents = "<?php\nreturn ['driver' => 'sqlite'];\n";
115+
116+
try {
117+
self::assertTrue(hasInstallConfigPermissions(dirname($path) . '/evo-new-config-' . uniqid() . '.php'));
118+
self::assertTrue(hasInstallConfigPermissions($path));
119+
self::assertTrue(writeInstallConfigFile($path, $contents));
120+
self::assertSame($contents, file_get_contents($path));
121+
self::assertTrue(is_readable($path));
122+
self::assertTrue(is_writable($path));
123+
} finally {
124+
@chmod($path, 0600);
125+
@unlink($path);
126+
}
127+
}
128+
129+
public function testConfigWriterRejectsAPartialWrite(): void
130+
{
131+
$scheme = 'evopartial' . bin2hex(random_bytes(4));
132+
self::assertTrue(stream_wrapper_register($scheme, PartialInstallConfigWriteStream::class));
133+
134+
try {
135+
self::assertFalse(writeInstallConfigFile($scheme . '://default.php', 'generated config'));
136+
} finally {
137+
stream_wrapper_unregister($scheme);
138+
}
139+
}
140+
141+
public function testCliWriteConfigThrowsWhenTheTargetCannotBeOpened(): void
142+
{
143+
$missingPath = sys_get_temp_dir() . '/evo-missing-' . uniqid('', true) . '/default.php';
144+
$installer = new class([], $missingPath) extends \InstallEvo {
145+
public function __construct(array $arguments, private readonly string $path)
146+
{
147+
parent::__construct($arguments);
148+
}
149+
150+
protected function configFilePath(): string
151+
{
152+
return $this->path;
153+
}
154+
};
155+
$installer->databaseType = 'sqlite';
156+
$installer->database = 'evolution';
157+
$installer->tablePrefix = 'evo_';
158+
159+
$this->expectException(\RuntimeException::class);
160+
$this->expectExceptionMessage('Unable to write the database configuration file');
161+
162+
$installer->writeConfig();
163+
}
67164
}
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
<?php
2+
3+
namespace Tests\Unit\Install;
4+
5+
use PHPUnit\Framework\TestCase;
6+
7+
final class WebInstallConfigFailureTest extends TestCase
8+
{
9+
public function testWebInstallerStopsBeforeBootstrappingAfterConfigWriteFailure(): void
10+
{
11+
$source = file_get_contents(dirname(__DIR__, 4) . '/install/src/controllers/install.php');
12+
13+
self::assertIsString($source);
14+
self::assertStringContainsString(
15+
'$configFileFailed = !writeInstallConfigFile($filename, $configString);',
16+
$source
17+
);
18+
self::assertMatchesRegularExpression(
19+
'/if \(\$configFileFailed === true\).*?include .*?template\/actions\/install\.php.*?return;/s',
20+
$source
21+
);
22+
}
23+
24+
public function testGeneratedConfigIsEscapedInTheFailureResponse(): void
25+
{
26+
$source = file_get_contents(dirname(__DIR__, 4) . '/install/src/template/actions/install.php');
27+
28+
self::assertIsString($source);
29+
self::assertStringContainsString(
30+
"htmlspecialchars(\$configString, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')",
31+
$source
32+
);
33+
}
34+
35+
public function testInstallerLocalesRetainTheEnglishFallback(): void
36+
{
37+
$source = file_get_contents(dirname(__DIR__, 4) . '/install/src/lang.php');
38+
39+
self::assertIsString($source);
40+
self::assertStringContainsString('$fallbackLang = $_lang;', $source);
41+
self::assertStringContainsString('$_lang += $fallbackLang;', $source);
42+
}
43+
44+
public function testEveryInstallerLocaleDefinesTheConfigWriteMessages(): void
45+
{
46+
$languageFiles = glob(dirname(__DIR__, 4) . '/install/src/lang/*.inc.php');
47+
self::assertIsArray($languageFiles);
48+
self::assertNotEmpty($languageFiles);
49+
50+
foreach ($languageFiles as $languageFile) {
51+
$source = file_get_contents($languageFile);
52+
self::assertIsString($source);
53+
self::assertStringContainsString(
54+
"'cant_write_config_file_retry'",
55+
$source,
56+
basename($languageFile) . ' must translate the config write failure instructions.'
57+
);
58+
self::assertStringContainsString(
59+
"'checking_if_database_config_writable'",
60+
$source,
61+
basename($languageFile) . ' must translate the config writability check.'
62+
);
63+
foreach (['0644', 'rw-r--r--', '0755', 'rwxr-xr-x', '0777'] as $permission) {
64+
self::assertStringContainsString(
65+
$permission,
66+
$source,
67+
basename($languageFile) . " must include the {$permission} permission guidance."
68+
);
69+
}
70+
}
71+
}
72+
73+
public function testConfigFailureLayoutHandlesLongTranslatedText(): void
74+
{
75+
$template = file_get_contents(dirname(__DIR__, 4) . '/install/src/template/actions/install.php');
76+
$styles = file_get_contents(dirname(__DIR__, 4) . '/install/style.css');
77+
$layout = file_get_contents(dirname(__DIR__, 4) . '/install/src/template/install.tpl');
78+
79+
self::assertIsString($template);
80+
self::assertIsString($styles);
81+
self::assertIsString($layout);
82+
self::assertStringContainsString('class="config-write-failure"', $template);
83+
self::assertStringContainsString('class="config-write-failure__content"', $template);
84+
self::assertStringContainsString('overflow-wrap: anywhere;', $styles);
85+
self::assertStringContainsString('word-break: break-all;', $styles);
86+
self::assertStringContainsString('max-width: 40rem;', $styles);
87+
self::assertStringContainsString('direction: ltr;', $styles);
88+
self::assertStringContainsString('text-align: left;', $styles);
89+
self::assertStringContainsString('box-sizing: border-box;', $styles);
90+
self::assertStringContainsString('@media (max-width: 600px)', $styles);
91+
self::assertStringContainsString('name="viewport"', $layout);
92+
}
93+
94+
public function testUpgradeModeBlocksAnUnreadableDatabaseConfig(): void
95+
{
96+
$controller = file_get_contents(dirname(__DIR__, 4) . '/install/src/controllers/mode.php');
97+
$template = file_get_contents(dirname(__DIR__, 4) . '/install/src/template/actions/mode.tpl');
98+
99+
self::assertIsString($controller);
100+
self::assertIsString($template);
101+
self::assertStringContainsString('!is_readable($databaseConfigFile)', $controller);
102+
self::assertStringContainsString("\$ph['disabledAdvUpg']", $controller);
103+
self::assertStringContainsString("\$ph['configPermissionError']", $controller);
104+
self::assertStringContainsString('[+configPermissionError+]', $template);
105+
self::assertStringContainsString('[+disabledNext+]', $template);
106+
}
107+
108+
public function testSummaryCacheWriterDoesNotUseUncheckedStreamHandles(): void
109+
{
110+
$source = file_get_contents(dirname(__DIR__, 4) . '/install/src/controllers/summary.php');
111+
112+
self::assertIsString($source);
113+
self::assertStringContainsString("\$_lang['cant_write_config_file_retry']", $source);
114+
self::assertStringContainsString('file_put_contents($path, $data, LOCK_EX)', $source);
115+
self::assertStringNotContainsString('fwrite($hnd, $data)', $source);
116+
}
117+
}

install/cli-install.php

Lines changed: 22 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,22 @@
2121
* Use --skipComposer=y to keep the already installed dependencies untouched.
2222
**/
2323

24-
function runCliInstall(array $argv): void
24+
function runCliInstall(array $argv): int
2525
{
26-
$install = new InstallEvo($argv);
27-
$install->start();
26+
try {
27+
$install = new InstallEvo($argv);
28+
$install->start();
29+
} catch (RuntimeException $exception) {
30+
error($exception->getMessage());
31+
32+
return 1;
33+
}
34+
35+
return 0;
2836
}
2937

3038
if (realpath($_SERVER['SCRIPT_FILENAME'] ?? '') === __FILE__) {
31-
runCliInstall($argv);
39+
exit(runCliInstall($argv));
3240
}
3341

3442
class InstallEvo
@@ -544,25 +552,18 @@ public function writeConfig()
544552
$configString = file_get_contents(__DIR__ . '/stubs/files/config/database/connections/default.tpl');
545553
$configString = parse($configString, $confph);
546554

547-
$filename = EVO_CORE_PATH . 'config/database/connections/default.php';
548-
$configFileFailed = false;
549-
550-
if (file_exists($filename)) {
551-
@chmod($filename, 0777);
552-
}
553-
554-
if (!$handle = fopen($filename, 'w')) {
555-
$configFileFailed = true;
556-
}
557-
// write $somecontent to our opened file.
558-
if (@ fwrite($handle, $configString) === false) {
559-
$configFileFailed = true;
555+
$filename = $this->configFilePath();
556+
if (!writeInstallConfigFile($filename, $configString)) {
557+
throw new RuntimeException(
558+
"Unable to write the database configuration file: {$filename}. "
559+
. 'Check the file and directory permissions, then run the installer again.'
560+
);
560561
}
561-
@ fclose($handle);
562-
563-
// try to chmod the config file go-rwx (for suexeced php)
564-
@chmod($filename, 0404);
562+
}
565563

564+
protected function configFilePath(): string
565+
{
566+
return EVO_CORE_PATH . 'config/database/connections/default.php';
566567
}
567568

568569
public function migrationAndSeed()

install/src/controllers/install.php

Lines changed: 3 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -124,26 +124,12 @@
124124
$configString = parse($configString, $confph);
125125

126126
$filename = EVO_CORE_PATH . 'config/database/connections/default.php';
127-
$configFileFailed = false;
127+
$configFileFailed = !writeInstallConfigFile($filename, $configString);
128128

129-
if (file_exists($filename)) {
130-
@chmod($filename, 0777);
131-
}
132-
133-
if (@!$handle = fopen($filename, 'w')) {
134-
$configFileFailed = true;
135-
}
136-
137-
// write $somecontent to our opened file.
138-
if (@fwrite($handle, $configString) === false) {
139-
$configFileFailed = true;
140-
}
141-
@fclose($handle);
142-
143-
// try to chmod the config file go-rwx (for suexeced php)
144-
@chmod($filename, 0404);
145129
if ($configFileFailed === true) {
146130
$errors += 1;
131+
include dirname(__DIR__) . '/template/actions/install.php';
132+
return;
147133
} else {
148134
$installLevel = 3;
149135
}

install/src/controllers/mode.php

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,16 @@
33
// Determine upgradeability
44
$isConnectable = false;
55
$installMode = isset($_POST['installmode']) ? (int)$_POST['installmode'] : 0;
6+
$databaseConfigFile = EVO_CORE_PATH . 'config/database/connections/default.php';
7+
$databaseConfigUnreadable = is_file($databaseConfigFile) && !is_readable($databaseConfigFile);
68

7-
if (!is_file(EVO_CORE_PATH . 'config/database/connections/default.php')) {
9+
if (!is_file($databaseConfigFile)) {
810
$isNew = true;
11+
} elseif ($databaseConfigUnreadable) {
12+
$isNew = false;
913
} else {
1014
$isNew = false;
11-
$db_config = include_once EVO_CORE_PATH . 'config/database/connections/default.php';
15+
$db_config = include_once $databaseConfigFile;
1216
if (isset($db_config['database'])) {
1317
try {
1418
$pdoOptions = [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION];
@@ -32,11 +36,14 @@
3236
$ph['displayUpg'] = $isNew ? 'hidden' : '';
3337
$ph['displayAdvUpg'] = $ph['displayUpg'];
3438
$ph['checkedNew'] = $isNew ? 'checked' : '';
35-
$ph['checkedUpg'] = ((!$isNew && $isConnectable) || ($installMode === 1)) ? 'checked' : '';
36-
$ph['checkedAdvUpg'] = ((!$isNew && !$isConnectable) || ($installMode === 2)) ? 'checked' : '';
39+
$ph['checkedUpg'] = (!$databaseConfigUnreadable && ((!$isNew && $isConnectable) || ($installMode === 1))) ? 'checked' : '';
40+
$ph['checkedAdvUpg'] = (!$databaseConfigUnreadable && ((!$isNew && !$isConnectable) || ($installMode === 2))) ? 'checked' : '';
3741
$ph['install_language'] = $install_language;
3842
$ph['disabledUpg'] = !$isConnectable ? 'disabled' : '';
39-
$ph['disabledAdvUpg'] = '';
43+
$ph['disabledAdvUpg'] = $databaseConfigUnreadable ? 'disabled' : '';
44+
$ph['configPermissionError'] = $databaseConfigUnreadable ? $_lang['cant_write_config_file_retry'] : '';
45+
$ph['configPermissionErrorHidden'] = $databaseConfigUnreadable ? '' : 'hidden';
46+
$ph['disabledNext'] = $databaseConfigUnreadable ? 'disabled' : '';
4047
$ph['csrf_nonce'] = csrfNonce();
4148

4249
$tpl = file_get_contents(dirname(__DIR__) . '/template/actions/mode.tpl');

0 commit comments

Comments
 (0)