From da654f6651b78e675fe65ad3ebc56288205fa417 Mon Sep 17 00:00:00 2001 From: Matt Friedman Date: Sun, 2 Aug 2026 13:20:33 -0700 Subject: [PATCH 1/2] Ownership pass skips bind-mounted extensions/styles --- .../Sandbox/DockerComposeWriter.php | 49 +++++++++++++++++-- tests/Unit/DockerComposeWriterTest.php | 39 +++++++++++++++ 2 files changed, 83 insertions(+), 5 deletions(-) diff --git a/src/QuickInstall/Sandbox/DockerComposeWriter.php b/src/QuickInstall/Sandbox/DockerComposeWriter.php index 70d38b96..df250f72 100644 --- a/src/QuickInstall/Sandbox/DockerComposeWriter.php +++ b/src/QuickInstall/Sandbox/DockerComposeWriter.php @@ -39,7 +39,7 @@ public function write(string $name, array $config): array $this->writeFile($installConfig, $this->installConfig($name, $config)); $this->writeFile($compose, $this->compose($name, $config)); $this->writeFile($dockerfile, $this->dockerfile($config)); - $this->writeFile($entrypoint, $this->entrypoint()); + $this->writeFile($entrypoint, $this->entrypoint($config)); if ((PHP_OS_FAMILY !== 'Windows') && !chmod($entrypoint, 0755)) { throw new RuntimeException("Unable to make entrypoint executable: $entrypoint"); @@ -253,9 +253,11 @@ private function aptSourceSetup(string $phpVersion): string . " && "; } - private function entrypoint(): string + private function entrypoint(array $config): string { - return <<<'SH' + $ownershipCommand = $this->ownershipCommand($config); + + $entrypoint = <<<'SH' set -eu if [ ! -f /var/www/html/common.php ]; then @@ -265,7 +267,7 @@ private function entrypoint(): string fi cp -R /opt/phpbb-source/. /var/www/html/ - chown -R www-data:www-data /var/www/html + __QUICKINSTALL_OWNERSHIP_COMMAND__ fi if [ -f /var/www/html/config.php ] && [ ! -s /var/www/html/config.php ]; then @@ -281,11 +283,48 @@ private function entrypoint(): string echo "Host timezone '$QUICKINSTALL_BOARD_TIMEZONE' is unsupported by this PHP runtime; using UTC." php /var/www/html/bin/phpbbcli.php config:set board_timezone UTC fi - chown -R www-data:www-data /var/www/html + __QUICKINSTALL_OWNERSHIP_COMMAND__ fi apache2-foreground SH; + + return str_replace('__QUICKINSTALL_OWNERSHIP_COMMAND__', $ownershipCommand, $entrypoint); + } + + /** Builds an ownership command that does not enter host bind mounts. */ + private function ownershipCommand(array $config): string + { + $mountTargets = []; + foreach (($config['extensions'] ?? []) as $name => $extension) + { + if (($extension['mode'] ?? '') === 'bind' && ($extension['source'] ?? '') !== '') + { + $mountTargets[] = '/var/www/html/ext/' . $name; + } + } + foreach (($config['styles'] ?? []) as $name => $style) + { + if (($style['mode'] ?? '') === 'bind' && ($style['source'] ?? '') !== '') + { + $mountTargets[] = '/var/www/html/styles/' . $name; + } + } + + $command = 'find /var/www/html'; + foreach ($mountTargets as $target) + { + $command .= " \\\n\t\t-path " . $this->shellString($target) . ' -prune -o'; + } + $command .= " \\\n\t\t-exec chown www-data:www-data {} +"; + + return $command; + } + + /** Quotes one string for the generated POSIX shell script. */ + private function shellString(string $value): string + { + return "'" . str_replace("'", "'\"'\"'", $value) . "'"; } private function databaseService(string $db, string $name, string $phpVersion): string diff --git a/tests/Unit/DockerComposeWriterTest.php b/tests/Unit/DockerComposeWriterTest.php index 942ee51f..ec0339fe 100644 --- a/tests/Unit/DockerComposeWriterTest.php +++ b/tests/Unit/DockerComposeWriterTest.php @@ -157,6 +157,45 @@ public function testQuotesYamlSignificantInstallerValues(): void self::assertStringContainsString(' name: "demo"', $installConfig); } + public function testEntrypointExcludesBindMountsFromOwnershipChanges(): void + { + [, $paths] = $this->writeBoard('demo', [ + 'extensions' => [ + 'acme/bound' => ['mode' => 'bind', 'source' => '/tmp/acme-bound'], + 'acme/copied' => ['mode' => 'copy', 'source' => '/tmp/acme-copied'], + 'acme/missing' => ['mode' => 'bind', 'source' => ''], + ], + 'styles' => [ + 'bound style' => ['mode' => 'bind', 'source' => '/tmp/bound-style'], + 'copied' => ['mode' => 'copy', 'source' => '/tmp/copied-style'], + ], + ]); + + $entrypoint = file_get_contents($paths['entrypoint']); + + self::assertSame(2, substr_count($entrypoint, 'find /var/www/html')); + self::assertSame(2, substr_count($entrypoint, "-path '/var/www/html/ext/acme/bound' -prune -o")); + self::assertSame(2, substr_count($entrypoint, "-path '/var/www/html/styles/bound style' -prune -o")); + self::assertStringNotContainsString('/var/www/html/ext/acme/copied', $entrypoint); + self::assertStringNotContainsString('/var/www/html/ext/acme/missing', $entrypoint); + self::assertStringNotContainsString('/var/www/html/styles/copied', $entrypoint); + self::assertSame(2, substr_count($entrypoint, '-exec chown www-data:www-data {} +')); + } + + public function testEntrypointQuotesBindMountTargetsForPosixShell(): void + { + [, $paths] = $this->writeBoard('demo', [ + 'styles' => [ + "designer's style" => ['mode' => 'bind', 'source' => '/tmp/designer-style'], + ], + ]); + + self::assertStringContainsString( + "-path '/var/www/html/styles/designer'\"'\"'s style' -prune -o", + file_get_contents($paths['entrypoint']) + ); + } + private function config(array $overrides = []): array { return $overrides + [ From b4fb0b24602daa4bd494e0145f94e9b080cdd4e1 Mon Sep 17 00:00:00 2001 From: Matt Friedman Date: Sun, 2 Aug 2026 13:21:03 -0700 Subject: [PATCH 2/2] normalize registry keys to strings before strict comparison, --- src/QuickInstall/Sandbox/Project.php | 2 +- tests/Unit/ProjectTest.php | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/QuickInstall/Sandbox/Project.php b/src/QuickInstall/Sandbox/Project.php index 752a13f0..a7e070d4 100644 --- a/src/QuickInstall/Sandbox/Project.php +++ b/src/QuickInstall/Sandbox/Project.php @@ -277,7 +277,7 @@ public function appendBoard(array $board): void $boards = $this->readJson('boards.json', []); foreach (array_keys($boards) as $registeredName) { - if ($registeredName !== $name && $this->namesEqual((string) $registeredName, $name)) + if ((string) $registeredName !== $name && $this->namesEqual((string) $registeredName, $name)) { throw new InvalidArgumentException("Board already exists: $registeredName. Board names are case-insensitive."); } diff --git a/tests/Unit/ProjectTest.php b/tests/Unit/ProjectTest.php index 42a6c761..952e2b5b 100644 --- a/tests/Unit/ProjectTest.php +++ b/tests/Unit/ProjectTest.php @@ -106,6 +106,25 @@ public function testBoardRegistryRejectsNamesThatDifferOnlyByCase(): void $project->appendBoard(['name' => 'Demo']); } + public function testBoardRegistryUpdatesNumericName(): void + { + $project = new Project($this->createTempProjectRoot()); + $project->init(); + $project->appendBoard(['name' => '322', 'extensions' => []]); + + $project->appendBoard([ + 'name' => '322', + 'extensions' => [ + 'vse/abbc3' => ['mode' => 'bind', 'source' => '/tmp/abbc3'], + ], + ]); + + self::assertSame( + ['mode' => 'bind', 'source' => '/tmp/abbc3'], + $project->board('322')['extensions']['vse/abbc3'] + ); + } + public function testDeleteTreeRefusesPathOutsideWorkspace(): void { $root = $this->createTempProjectRoot();