Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,9 +152,12 @@ line to add.
`uninstall` restores it.
- **Config carried over.** Your existing ini configuration is copied to the new
interpreter's config path. Xdebug loaders are removed (the debugger provides its
own simulated xdebug), other extension loaders are commented out (a
self-contained build can't load foreign `.so` files), and `xdebug.mode` is
constrained to `off`/`debug` (with a prompt if other modes are present).
own simulated xdebug) and `xdebug.mode` is constrained to `off`/`debug` (with a
prompt if other modes are present). If the new interpreter is the same PHP
version and thread-safety as the old one, your other extensions keep loading:
their `.so`/`.dll` files are copied into the new interpreter's `extension_dir`
(and removed again on `uninstall`). Otherwise their loaders are commented out,
since extensions built for a different PHP version can't load.
- **Post-install check + rollback.** After activating, it confirms the new `php`
runs and reports the `php_debugger` module. Any failure rolls everything back to
the prior working state.
Expand Down
125 changes: 125 additions & 0 deletions internal/installer/extfiles.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
package installer

import (
"context"
"fmt"
"os"
"path/filepath"
"strings"

"github.com/php-debugger/installer/internal/php"
)

// sameABI reports whether extensions built for existing can load in target: same
// PHP series and same thread-safety. When true, the existing php's extension
// loaders are kept active in the copied config and its extension binaries are
// copied over (see copyExtensionFiles).
func sameABI(existing, target *php.Info) bool {
return existing.Series == target.Series && existing.ZTS == target.ZTS
}

// copyExtensions copies the existing php's extension binaries into the new
// interpreter's extension_dir. It must run after the config was copied: the new
// interpreter (already placed at binTarget) is re-queried so its effective
// extension_dir reflects the copied ini — if that sets extension_dir explicitly
// (e.g. to the old php's dir), the loaders already resolve and nothing is copied.
// Relative dirs (Windows' "ext") are resolved against each php's own binary.
func copyExtensions(ctx context.Context, existing, target *php.Info, binTarget string, rb *rollback, opts Options) ([]string, error) {
targetDir := target.ExtensionDir
if q, err := php.Query(ctx, binTarget); err == nil && q.ExtensionDir != "" {
targetDir = q.ExtensionDir
}
if existing.ExtensionDir == "" || targetDir == "" {
return nil, nil
}
return copyExtensionFiles(
resolveExtensionDir(existing.ExtensionDir, existing.Path),
resolveExtensionDir(targetDir, binTarget),
rb, opts)
}

// copyExtensionFiles copies the shared extension binaries (.so / .dll) from the
// replaced php's extension_dir into the new interpreter's, so the loaders kept in
// the copied config (e.g. `extension=redis`, resolved against extension_dir) find
// their files. It is only meaningful for a same-ABI replacement; otherwise the
// loaders are commented out and the binaries could not load anyway.
//
// xdebug and the standalone php-debugger extension are skipped (their loaders are
// always stripped). A file already present at the destination is never
// overwritten: it may belong to the new interpreter or to the user. Symlinks in the
// source (e.g. Homebrew's opcache.so) are followed and their target copied. It
// registers removal of each copy on rb and returns the files written, which
// uninstall deletes.
func copyExtensionFiles(srcDir, dstDir string, rb *rollback, opts Options) ([]string, error) {
if srcDir == "" || dstDir == "" || sameFile(srcDir, dstDir) {
return nil, nil
}
entries, err := os.ReadDir(srcDir)
if os.IsNotExist(err) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("reading extension dir %s: %w", srcDir, err)
}

var copied []string
for _, e := range entries {
name := e.Name()
if !isExtensionBinary(name) || isDebuggerBinary(name) {
continue
}
src := filepath.Join(srcDir, name)
fi, err := os.Stat(src) // follows symlinks
if err != nil || !fi.Mode().IsRegular() {
continue // dangling symlink or not a file
}
dst := filepath.Join(dstDir, name)
if _, err := os.Lstat(dst); err == nil {
continue
} else if !os.IsNotExist(err) {
return copied, fmt.Errorf("inspecting %s: %w", dst, err)
}
if err := copyFile(src, dst, fi.Mode().Perm()); err != nil {
_ = removeIfExists(dst)
return copied, fmt.Errorf("copying extension %s to %s: %w", src, dstDir, err)
}
d := dst
rb.add(func() error { return removeIfExists(d) })
copied = append(copied, dst)
}
if len(copied) > 0 {
opts.logf(" copied %d extension file(s) from %s to %s", len(copied), srcDir, dstDir)
}
return copied, nil
}

// isExtensionBinary reports whether name looks like a PHP shared extension.
func isExtensionBinary(name string) bool {
ext := strings.ToLower(filepath.Ext(name))
return ext == ".so" || ext == ".dll"
}

// isDebuggerBinary reports whether name is an xdebug or php-debugger extension
// binary. Both are superseded by the interpreter's built-in debugger, and their
// loaders are stripped from the copied config, so they are never copied.
func isDebuggerBinary(name string) bool {
n := strings.ToLower(name)
return strings.Contains(n, "xdebug") || strings.Contains(n, "php-debugger") ||
strings.Contains(n, "php_debugger")
}

// mergePaths returns fresh plus any path from prior not already in fresh.
func mergePaths(fresh, prior []string) []string {
seen := make(map[string]bool, len(fresh))
for _, p := range fresh {
seen[p] = true
}
merged := append([]string(nil), fresh...)
for _, p := range prior {
if !seen[p] {
seen[p] = true
merged = append(merged, p)
}
}
return merged
}
231 changes: 231 additions & 0 deletions internal/installer/extfiles_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
package installer

import (
"bytes"
"context"
"os"
"path/filepath"
"runtime"
"sort"
"strings"
"testing"

"github.com/php-debugger/installer/internal/manifest"
"github.com/php-debugger/installer/internal/platform"
"github.com/php-debugger/installer/internal/release"
)

func writeFile(t *testing.T, path, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}

func baseNames(paths []string) []string {
var out []string
for _, p := range paths {
out = append(out, filepath.Base(p))
}
sort.Strings(out)
return out
}

func TestCopyExtensionFiles(t *testing.T) {
src := t.TempDir()
dst := filepath.Join(t.TempDir(), "ext") // does not exist yet

writeFile(t, filepath.Join(src, "redis.so"), "REDIS")
writeFile(t, filepath.Join(src, "php_intl.dll"), "INTL")
writeFile(t, filepath.Join(src, "xdebug.so"), "XDEBUG")
writeFile(t, filepath.Join(src, "php-debugger-php8.3-nts.so"), "DBG")
writeFile(t, filepath.Join(src, "README.txt"), "not an extension")
if err := os.MkdirAll(filepath.Join(src, "sub.so"), 0o755); err != nil {
t.Fatal(err)
}
// Already present at the destination: must not be overwritten.
writeFile(t, filepath.Join(src, "opcache.so"), "FOREIGN-OPCACHE")
writeFile(t, filepath.Join(dst, "opcache.so"), "OWN-OPCACHE")
// A symlinked extension (as Homebrew does) is copied by content.
if runtime.GOOS != "windows" {
real := filepath.Join(t.TempDir(), "apcu.so")
writeFile(t, real, "APCU")
if err := os.Symlink(real, filepath.Join(src, "apcu.so")); err != nil {
t.Fatal(err)
}
if err := os.Symlink(filepath.Join(src, "missing"), filepath.Join(src, "dangling.so")); err != nil {
t.Fatal(err)
}
}

rb := &rollback{}
copied, err := copyExtensionFiles(src, dst, rb, Options{})
if err != nil {
t.Fatalf("copyExtensionFiles: %v", err)
}

want := []string{"php_intl.dll", "redis.so"}
if runtime.GOOS != "windows" {
want = []string{"apcu.so", "php_intl.dll", "redis.so"}
}
if got := baseNames(copied); strings.Join(got, ",") != strings.Join(want, ",") {
t.Fatalf("copied %v, want %v", got, want)
}
if b, _ := os.ReadFile(filepath.Join(dst, "redis.so")); string(b) != "REDIS" {
t.Errorf("redis.so content = %q", b)
}
if runtime.GOOS != "windows" {
fi, err := os.Lstat(filepath.Join(dst, "apcu.so"))
if err != nil || !fi.Mode().IsRegular() {
t.Errorf("symlinked extension should be copied as a regular file: %v %v", fi, err)
}
}
if b, _ := os.ReadFile(filepath.Join(dst, "opcache.so")); string(b) != "OWN-OPCACHE" {
t.Errorf("existing destination file was overwritten: %q", b)
}
for _, skipped := range []string{"xdebug.so", "php-debugger-php8.3-nts.so", "README.txt", "dangling.so"} {
if _, err := os.Lstat(filepath.Join(dst, skipped)); err == nil {
t.Errorf("%s should not have been copied", skipped)
}
}

// Rollback removes exactly the copies, leaving the pre-existing file.
rb.run()
for _, f := range copied {
if _, err := os.Lstat(f); err == nil {
t.Errorf("rollback left %s", f)
}
}
if _, err := os.Stat(filepath.Join(dst, "opcache.so")); err != nil {
t.Errorf("rollback removed a pre-existing file: %v", err)
}
}

func TestCopyExtensionFilesNoOp(t *testing.T) {
dir := t.TempDir()
writeFile(t, filepath.Join(dir, "redis.so"), "REDIS")
rb := &rollback{}
for _, tc := range []struct{ name, src, dst string }{
{"same dir", dir, dir},
{"missing source", filepath.Join(dir, "nope"), t.TempDir()},
{"empty source", "", t.TempDir()},
{"empty destination", dir, ""},
} {
copied, err := copyExtensionFiles(tc.src, tc.dst, rb, Options{})
if err != nil || len(copied) != 0 {
t.Errorf("%s: copied %v, err %v; want no-op", tc.name, copied, err)
}
}
}

func TestMergePaths(t *testing.T) {
got := mergePaths([]string{"a", "b"}, []string{"b", "c", "c"})
if strings.Join(got, ",") != "a,b,c" {
t.Errorf("mergePaths = %v", got)
}
}

// withExtDir makes a fake php script report extDir as its extension_dir.
func withExtDir(script, extDir string) string {
return strings.ReplaceAll(script, "extension_dir=/fake/ext", "extension_dir="+extDir)
}

// Replacing a same-ABI php copies its extension binaries into the new
// interpreter's extension_dir (so the kept loaders resolve), records them, and
// uninstall removes them again.
func TestInstallInterpreterSameABICopiesExtensions(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("fake php is a /bin/sh script")
}
home := t.TempDir()

newCfg := filepath.Join(t.TempDir(), "newcfg")
newExt := filepath.Join(t.TempDir(), "lib", "extensions", "no-debug-non-zts-20230831")
srv := newFakeReleaseServer(t, withExtDir(fakePHP("8.3.12", true, newCfg, filepath.Join(newCfg, "conf.d")), newExt))

existBin := filepath.Join(t.TempDir(), "bin")
existIni := t.TempDir()
existExt := t.TempDir()
writeExec(t, filepath.Join(existBin, "php"), withExtDir(existingPHPScript("8.3.4",
filepath.Join(existIni, "php.ini"), filepath.Join(existIni, "conf.d"), ""), existExt))
writeFile(t, filepath.Join(existIni, "php.ini"), "zend_extension=xdebug\nextension=redis\n")
writeFile(t, filepath.Join(existExt, "redis.so"), "REDIS")
writeFile(t, filepath.Join(existExt, "xdebug.so"), "XDEBUG")
t.Setenv("PATH", existBin)

client := release.NewClient()
client.BaseURL = srv.URL
env := linuxUserEnv(home)
opts := Options{Scope: platform.User, AssumeYes: true, Client: client, Env: &env}

var out bytes.Buffer
opts.Out = &out
if err := InstallInterpreter(context.Background(), opts); err != nil {
t.Fatalf("InstallInterpreter: %v\n--- output ---\n%s", err, out.String())
}

if b, err := os.ReadFile(filepath.Join(newExt, "redis.so")); err != nil || string(b) != "REDIS" {
t.Errorf("redis.so not copied to the new extension_dir: %q %v", b, err)
}
if _, err := os.Stat(filepath.Join(newExt, "xdebug.so")); err == nil {
t.Error("xdebug.so must not be copied")
}
root := filepath.Join(home, ".local", "share", "php-debugger")
m, err := manifest.Load(filepath.Join(root, "manifest.json"))
if err != nil {
t.Fatal(err)
}
it, _ := m.Interpreter("8.3")
if len(it.ExtensionFiles) != 1 || it.ExtensionFiles[0] != filepath.Join(newExt, "redis.so") {
t.Errorf("ExtensionFiles = %v", it.ExtensionFiles)
}

// Uninstall removes the copied binary and prunes the now-empty dir; the
// original php's extension stays untouched.
if err := Uninstall(context.Background(), opts, "", false); err != nil {
t.Fatalf("Uninstall: %v\n--- output ---\n%s", err, out.String())
}
if _, err := os.Stat(newExt); !os.IsNotExist(err) {
t.Errorf("copied extension dir should be removed on uninstall: %v", err)
}
if _, err := os.Stat(filepath.Join(existExt, "redis.so")); err != nil {
t.Errorf("original extension must be left alone: %v", err)
}
}

// A different-ABI replacement comments the loaders out, so nothing is copied.
func TestInstallInterpreterDifferentABISkipsExtensions(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("fake php is a /bin/sh script")
}
home := t.TempDir()

newCfg := filepath.Join(t.TempDir(), "newcfg")
newExt := filepath.Join(t.TempDir(), "newext")
srv := newFakeReleaseServer(t, withExtDir(fakePHP("8.3.12", true, newCfg, filepath.Join(newCfg, "conf.d")), newExt))

existBin := filepath.Join(t.TempDir(), "bin")
existIni := t.TempDir()
existExt := t.TempDir()
writeExec(t, filepath.Join(existBin, "php"), withExtDir(existingPHPScript("8.2.9",
filepath.Join(existIni, "php.ini"), filepath.Join(existIni, "conf.d"), ""), existExt))
writeFile(t, filepath.Join(existIni, "php.ini"), "extension=redis\n")
writeFile(t, filepath.Join(existExt, "redis.so"), "REDIS")
t.Setenv("PATH", existBin)

client := release.NewClient()
client.BaseURL = srv.URL
env := linuxUserEnv(home)
var out bytes.Buffer
if err := InstallInterpreter(context.Background(), Options{
Scope: platform.User, AssumeYes: true, Out: &out, Client: client, Env: &env,
}); err != nil {
t.Fatalf("InstallInterpreter: %v\n--- output ---\n%s", err, out.String())
}
if _, err := os.Stat(filepath.Join(newExt, "redis.so")); err == nil {
t.Error("different ABI: extensions must not be copied")
}
}
5 changes: 3 additions & 2 deletions internal/installer/iniconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,8 @@ func saveIniBackup(backupDir, originalPath string, data []byte) (string, error)
// interpreter has a different ABI from the one it replaces — a foreign .so built
// for a different PHP version or thread-safety cannot load and would error on
// every run. When the replacement is the same PHP series and thread-safety, those
// extensions load fine, so their loaders are left intact.
// extensions load fine, so their loaders are left intact (and InstallInterpreter
// copies their binaries into the new interpreter's extension_dir).
func copyConfig(existing, target *php.Info, backupDir string, rb *rollback, opts Options) ([]string, []manifest.FileBackup, error) {
copyPairs, sharedPairs := splitInPlacePairs(interpreterConfigPairs(existing, target))
if len(copyPairs) == 0 && len(sharedPairs) == 0 {
Expand All @@ -159,7 +160,7 @@ func copyConfig(existing, target *php.Info, backupDir string, rb *rollback, opts
}
return nil, nil, nil
}
sameABI := existing.Series == target.Series && existing.ZTS == target.ZTS
sameABI := sameABI(existing, target)
if sameABI {
opts.logf(" (same PHP %s %s as before; keeping existing extension loaders)",
target.Series, threading(target.ZTS))
Expand Down
Loading
Loading