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) {