From d941c23ccdae22194773dc57e50d5cc53a3a2787 Mon Sep 17 00:00:00 2001 From: Alexandre Gomes Gaigalas Date: Mon, 21 Sep 2026 05:52:30 -0300 Subject: [PATCH] Keep the embed build inside its own prefix (#119) --skip-tests * fix(installer): keep the embed build inside its own prefix The libphp.so installer reuses the configure options of the PHP already on the machine so the private build matches it feature for feature. A distribution build records where *it* installs, though, and those paths are absolute. On Debian and Ubuntu the options carry --mandir=/usr/share/man, --includedir=/usr/include, --libdir, --libexecdir, --sysconfdir and --localstatedir. Inherited unchanged, they survive the --prefix rewrite, and `make install` tries to write into system directories: Installing PHP CLI man page: /usr/share/man/man1/ cp: cannot create regular file '/usr/share/man/man1/#INST@3023727#': Permission denied make: *** [Makefile:265: install-cli] Error 1 Fatal error: Unable to install libphp.so: Command failed: make install So the build only completed for root, or inside a container where the user owns /usr. Everyone else got a PHP compiled from scratch and then thrown away at the last step. Two further options escape the same way. --program-suffix=8.3 installs the binaries as php8.3 and php-config8.3, but LibPhpInstaller::installedVersion(), writePhpIni() and UnixPlatform::findPhpConfig() all look for the plain names, so a build that got past `make install` still looked like it had produced nothing. --cache-file points at the distribution's build directory (/tmp/buildd/nonexistent/config.cache), a path that does not exist here and whose answers were recorded for a different prefix. Drop all of them. Autoconf derives every installation directory from --prefix when it is absent, so the private build lands entirely under the requested prefix: bin/php, lib/libphp.so, include/php and share/man/man1. Options that name a *dependency* rather than a destination, such as --with-zlib=/usr and --with-layout=GNU, are untouched; the built PHP still matches the one it was derived from. * fix(installer): offer an existing build before asking to make one Running a build twice asked four questions to arrive at the answer it already had: The current PHP installation does not provide libphp.so: /usr Build a private PHP embed library now? [Y/n] PHP version [8.5.10]: Install directory [/home/alganet/.typephp]: PHP 8.5.10 with libphp.so already exists in /home/alganet/.typephp; use it? [Y/n] The reuse check sat at the bottom because it needs a version and a directory, and those were read from the prompts. But both have defaults that are known without asking anyone: the directory this installer always proposes, and the PHP that is running it. That is the case being answered here, so it can be offered first. Ask it up front. Accepting now ends the exchange at one question, and the `--prefix` rewrite no longer has to be reasoned about at all. The later check stays for a directory or version typed by hand, and is skipped when the answers repeat the defaults, so the same question is never asked twice. ensure() also fetched the release list from php.net before looking for an existing build, so the reuse path made a network round trip to learn the URL of an archive it was not going to download. Move the fetch next to the install that needs it. Nothing recorded PHP_HOME anywhere but this process, and that has not changed: a fresh run still has to rediscover the build. Being asked once instead of four times is the part that is worth fixing separately. --- .../Installer/PhpBuildConfigurationTest.php | 45 ++++++++++++++++++- src/Installer/LibPhpInstaller.php | 44 +++++++++++++----- src/Installer/PhpBuildConfiguration.php | 33 +++++++++++++- 3 files changed, 109 insertions(+), 13 deletions(-) diff --git a/phpunit/src/Installer/PhpBuildConfigurationTest.php b/phpunit/src/Installer/PhpBuildConfigurationTest.php index 5498a20d..d5eca6df 100644 --- a/phpunit/src/Installer/PhpBuildConfigurationTest.php +++ b/phpunit/src/Installer/PhpBuildConfigurationTest.php @@ -57,7 +57,6 @@ final class PhpBuildConfigurationTest extends TestCase ['--includedir=/usr/include', '--disable-all', '--with-zlib=/usr'], $parsed ); - self::assertContains('--includedir=/usr/include', $options); self::assertContains('--disable-all', $options); self::assertContains('--with-zlib=/usr', $options); self::assertNotContains('CFLAGS=-g', $options); @@ -78,6 +77,50 @@ final class PhpBuildConfigurationTest extends TestCase self::assertContains('--enable-cli', $options); } + public function testDeriveKeepsInstallationDirectoriesInsideThePrefix(): void + { + // A Debian/Ubuntu build records absolute installation directories; reusing + // them makes `make install` write to /usr/share/man and /usr/include, which + // an unprivileged user cannot do. + $options = PhpBuildConfiguration::derive( + "'--prefix=/usr' '--exec-prefix=/usr' '--includedir=/usr/include' " . + "'--mandir=/usr/share/man' '--infodir=/usr/share/info' '--sysconfdir=/etc' " . + "'--localstatedir=/var' '--libdir=\${prefix}/lib/php' " . + "'--libexecdir=/usr/lib/x86_64-linux-gnu' '--datadir=\${prefix}/share/php/8.5' " . + "'--with-layout=GNU' '--with-openssl'", + '/home/test/.typephp' + ); + + foreach ($options as $option) { + self::assertStringNotContainsString('/usr/', $option); + self::assertStringNotContainsString('${prefix}', $option); + } + self::assertSame(['--prefix=/home/test/.typephp'], array_values(array_filter( + $options, + static fn(string $option): bool => str_starts_with($option, '--prefix=') + ))); + self::assertContains('--with-layout=GNU', $options); + self::assertContains('--with-openssl', $options); + } + + public function testDeriveDropsProgramNameTransformsAndStaleConfigureCaches(): void + { + // --program-suffix would install bin/php8.5 instead of bin/php, which the + // installer and the platform probes both look for by its plain name. + $options = PhpBuildConfiguration::derive( + "'--program-suffix=8.5' '--program-prefix=x' '--program-transform-name=s,x,x,' " . + "'--config-cache' '--cache-file=/tmp/buildd/nonexistent/config.cache' '--enable-pcntl'", + '/home/test/.typephp' + ); + + self::assertNotContains('--program-suffix=8.5', $options); + self::assertNotContains('--program-prefix=x', $options); + self::assertNotContains('--program-transform-name=s,x,x,', $options); + self::assertNotContains('--config-cache', $options); + self::assertNotContains('--cache-file=/tmp/buildd/nonexistent/config.cache', $options); + self::assertContains('--enable-pcntl', $options); + } + public function testParseShellWordsRejectsIncompleteInput(): void { $this->expectException(\InvalidArgumentException::class); diff --git a/src/Installer/LibPhpInstaller.php b/src/Installer/LibPhpInstaller.php index ad9ed030..e808e1a7 100644 --- a/src/Installer/LibPhpInstaller.php +++ b/src/Installer/LibPhpInstaller.php @@ -25,27 +25,49 @@ final class LibPhpInstaller } $this->console->write("The current PHP installation does not provide libphp.so: {$currentPhpDir}"); + + $home = getenv('HOME') ?: (string) ($_SERVER['HOME'] ?? ''); + $defaultPrefix = rtrim($home, '/') . '/.typephp'; + $defaultVersion = PHP_VERSION; + + // A build left by an earlier run answers every question below, so offer + // it before asking any of them. Both defaults are known without asking: + // the directory this installer always proposes, and the running PHP. + if ($this->offerInstalled($defaultPrefix, $defaultVersion)) { + return $this->activate($defaultPrefix); + } + if (!$this->console->confirm('Build a private PHP embed library now?', true)) { return null; } - $defaultVersion = PHP_VERSION; $version = $this->console->ask("PHP version [{$defaultVersion}]: ", $defaultVersion); if (!preg_match('/^8\.[45]\.\d+$/', $version)) { throw new \RuntimeException('Only stable PHP 8.4.x and 8.5.x versions are supported by the automatic installer'); } - $release = $this->release($version); - $home = getenv('HOME') ?: (string) ($_SERVER['HOME'] ?? ''); - $defaultPrefix = rtrim($home, '/') . '/.typephp'; $prefix = $this->expandHome($this->console->ask("Install directory [{$defaultPrefix}]: ", $defaultPrefix), $home); - if ($this->hasLibPhp($prefix) && $this->installedVersion($prefix) === $version - && $this->console->confirm("PHP {$version} with libphp.so already exists in {$prefix}; use it?", true)) { - putenv('PHP_HOME=' . $prefix); - $_ENV['PHP_HOME'] = $prefix; - return $prefix; - } - $this->install($release, $prefix); + // The offer above covered only the default directory and the running + // PHP; the answers just given may name another build. + if (($prefix !== $defaultPrefix || $version !== $defaultVersion) + && $this->offerInstalled($prefix, $version)) { + return $this->activate($prefix); + } + // Reaching php.net is pointless until a build is known to be needed. + $this->install($this->release($version), $prefix); + return $this->activate($prefix); + } + + private function offerInstalled(string $prefix, string $version): bool + { + return $this->hasLibPhp($prefix) + && $this->installedVersion($prefix) === $version + && $this->console->confirm("PHP {$version} with libphp.so already exists in {$prefix}; use it?", true); + } + + /** Point this process, and the build it runs, at the selected installation. */ + private function activate(string $prefix): string + { putenv('PHP_HOME=' . $prefix); $_ENV['PHP_HOME'] = $prefix; return $prefix; diff --git a/src/Installer/PhpBuildConfiguration.php b/src/Installer/PhpBuildConfiguration.php index afc8d766..e0de4504 100644 --- a/src/Installer/PhpBuildConfiguration.php +++ b/src/Installer/PhpBuildConfiguration.php @@ -81,6 +81,34 @@ final class PhpBuildConfiguration return $options; } + /** + * Autoconf installation directories, program name transforms and cache files. + * + * A distribution build points these outside its own --prefix (--mandir=/usr/share/man, + * --includedir=/usr/include) or renames the installed binaries (--program-suffix=8.3). + * Inheriting them makes `make install` write to system paths the user cannot own, + * and hides bin/php behind a versioned name. Autoconf derives every one of them + * from --prefix when it is absent, so dropping them keeps the private build + * entirely inside the requested prefix. + * + * The versioned name is the Debian and Ubuntu packaging scheme, not a PPA + * addition: php8.3-dev in noble-updates/main carries --program-suffix=8.3 + * and --mandir=/usr/share/man, and ppa:ondrej/php repeats it per version. + * + * @var list + */ + private const array PREFIX_DERIVED = [ + '--exec-prefix', '--bindir', '--sbindir', '--libexecdir', '--sysconfdir', + '--sharedstatedir', '--localstatedir', '--runstatedir', '--libdir', + '--includedir', '--oldincludedir', '--datarootdir', '--datadir', + '--infodir', '--localedir', '--mandir', '--docdir', '--htmldir', + '--dvidir', '--pdfdir', '--psdir', + '--program-prefix', '--program-suffix', '--program-transform-name', + // A cache recorded for the distribution prefix answers the wrong questions + // here, and its path may not even exist on this machine. + '--cache-file', '--config-cache', + ]; + /** * @param string|list $configureOptions * @return list @@ -91,7 +119,10 @@ final class PhpBuildConfiguration '--prefix', '--with-config-file-path', '--with-config-file-scan-dir', '--enable-embed', '--enable-cli', '--disable-cli', '--with-libdir', ]; - $drop = ['--with-apxs', '--with-apxs2', '--enable-fpm', '--with-fpm-systemd']; + $drop = [ + '--with-apxs', '--with-apxs2', '--enable-fpm', '--with-fpm-systemd', + ...self::PREFIX_DERIVED, + ]; $result = []; $options = is_string($configureOptions) ? self::parseShellWords($configureOptions) : $configureOptions; foreach ($options as $option) {