diff --git a/README.md b/README.md index 20d3b95..30b9fd4 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/internal/installer/extfiles.go b/internal/installer/extfiles.go new file mode 100644 index 0000000..3b3ecd9 --- /dev/null +++ b/internal/installer/extfiles.go @@ -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 +} diff --git a/internal/installer/extfiles_test.go b/internal/installer/extfiles_test.go new file mode 100644 index 0000000..116b0a1 --- /dev/null +++ b/internal/installer/extfiles_test.go @@ -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") + } +} diff --git a/internal/installer/iniconfig.go b/internal/installer/iniconfig.go index a851108..e1a502e 100644 --- a/internal/installer/iniconfig.go +++ b/internal/installer/iniconfig.go @@ -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 { @@ -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)) diff --git a/internal/installer/install.go b/internal/installer/install.go index d5a1c63..3045e16 100644 --- a/internal/installer/install.go +++ b/internal/installer/install.go @@ -249,6 +249,7 @@ func InstallInterpreter(ctx context.Context, opts Options) error { // --- sanitize the ini config the new interpreter will load --- var configFiles []string var configBackups []manifest.FileBackup + var extFiles []string if existing != nil { // Replacing a foreign php: copy its configuration into the new interpreter's // config path (or sanitize it in place when they share a directory). @@ -258,6 +259,15 @@ func InstallInterpreter(ctx context.Context, opts Options) error { rb.run() return fmt.Errorf("copying ini configuration; rolled back: %w", err) } + // Same ABI: the existing php's extension loaders were kept, so copy their + // binaries into the new interpreter's extension_dir. + if sameABI(existing, info) { + extFiles, err = copyExtensions(ctx, existing, info, binTarget, rb, opts) + if err != nil { + rb.run() + return fmt.Errorf("copying extensions; rolled back: %w", err) + } + } } else { // No foreign php to copy from (e.g. switching versions while our own // interpreter is active, or a clean host). The new interpreter still reads its @@ -347,16 +357,18 @@ func InstallInterpreter(ctx context.Context, opts Options) error { // keeps freshly-captured entries and fills the rest from the previous record. if prev, ok := m.Interpreter(key); ok { configFiles, configBackups = preserveConfig(prev, configFiles, configBackups) + extFiles = mergePaths(extFiles, prev.ExtensionFiles) } m.SetInterpreter(key, manifest.Interpreter{ - Series: series, - PHPVersion: info.Version, - ZTS: opts.ZTS, - ReleaseTag: rel.TagName, - Dir: versionDir, - InstalledAt: opts.clock(), - ConfigFiles: configFiles, - ConfigBackups: configBackups, + Series: series, + PHPVersion: info.Version, + ZTS: opts.ZTS, + ReleaseTag: rel.TagName, + Dir: versionDir, + InstalledAt: opts.clock(), + ConfigFiles: configFiles, + ConfigBackups: configBackups, + ExtensionFiles: extFiles, }) m.SetActive(key) for i, b := range backups { diff --git a/internal/installer/uninstall.go b/internal/installer/uninstall.go index 640973e..9ff3d14 100644 --- a/internal/installer/uninstall.go +++ b/internal/installer/uninstall.go @@ -121,7 +121,7 @@ func uninstallInterpreter(opts Options, layout platform.Layout, m *manifest.Mani _ = removeIfExists(p) } - // Remove the copied config files (pruning now-empty dirs) and the versioned + // Remove the copied config and extension files (pruning now-empty dirs) and the versioned // directory. The active `php` no longer points into it. for _, f := range it.ConfigFiles { if err := removeIfExists(f); err != nil { @@ -129,6 +129,12 @@ func uninstallInterpreter(opts Options, layout platform.Layout, m *manifest.Mani } } pruneEmptyConfigDirs(it.ConfigFiles) + for _, f := range it.ExtensionFiles { + if err := removeIfExists(f); err != nil { + return fmt.Errorf("removing extension %s: %w", f, err) + } + } + pruneEmptyConfigDirs(it.ExtensionFiles) if err := os.RemoveAll(it.Dir); err != nil { return fmt.Errorf("removing %s: %w", it.Dir, err) } diff --git a/internal/manifest/manifest.go b/internal/manifest/manifest.go index 295b3f2..29836cf 100644 --- a/internal/manifest/manifest.go +++ b/internal/manifest/manifest.go @@ -45,6 +45,10 @@ type Interpreter struct { // compiled-in config path (copied from a replaced interpreter), recorded so // uninstall can remove them. ConfigFiles []string `json:"configFiles,omitempty"` + // ExtensionFiles are extension binaries (.so / .dll) this install copied from a + // replaced same-ABI interpreter's extension_dir into this interpreter's, so the + // loaders kept in the copied config resolve. Uninstall removes them. + ExtensionFiles []string `json:"extensionFiles,omitempty"` // ConfigBackups are the replaced interpreter's own ini files that this install // sanitized in place — necessary when the new interpreter's compiled-in config // path is the existing php's own directory, so the files cannot be copied