From 870032679e475fe2764eac48efad3f0b60793d72 Mon Sep 17 00:00:00 2001 From: Carlos Granados Date: Tue, 28 Jul 2026 13:56:22 +0200 Subject: [PATCH] Updates after testing --- README.md | 14 +- internal/cli/uninstall.go | 21 +- internal/cli/update.go | 24 +- internal/installer/backup.go | 24 +- internal/installer/cleanup_test.go | 2 +- internal/installer/extension.go | 14 +- internal/installer/extension_test.go | 132 ++++++++- internal/installer/iniconfig.go | 149 ++++++++++- internal/installer/install.go | 78 +++++- internal/installer/install_test.go | 386 ++++++++++++++++++++++++++- internal/installer/uninstall.go | 104 +++++--- internal/installer/uninstall_test.go | 54 +++- internal/installer/update.go | 48 +--- internal/installer/update_test.go | 37 ++- internal/manifest/manifest.go | 6 + 15 files changed, 921 insertions(+), 172 deletions(-) diff --git a/README.md b/README.md index 4c9097a..20d3b95 100644 --- a/README.md +++ b/README.md @@ -92,8 +92,8 @@ php-debugger uninstall | --- | --- | | `install` | Install the debugger **interpreter** (default) or the **extension** (`-e`). | | `switch ` | Make an installed version active, installing it first if needed. | -| `update` | Reinstall the active interpreter and/or extension against the latest release. | -| `uninstall [version]` | Remove an interpreter (or the extension), restoring any backup. | +| `update` | Reinstall whatever is installed (interpreter or extension) against the latest release. | +| `uninstall [version]` | Remove the installed debugger (interpreter or extension), restoring any backup. | ### Flags @@ -109,10 +109,14 @@ Global: - `-z, --zts` — thread-safe build (default: non-thread-safe; interpreter only). - `-e, --extension-only` — install only the extension into the current php. -`update` / `uninstall`: +`update` takes no flags — it updates whatever is installed (interpreter or +extension), detected automatically. -- `-e, --extension` / `-i, --interpreter` — pick the target when both are installed. -- `uninstall` also takes an optional `` and `-z, --zts` to target a variant. +`uninstall`: + +- optional `` and `-z, --zts` to target a specific interpreter variant. The + kind (interpreter or extension) is detected automatically — the two are never + installed at once, since installing the interpreter removes any extension. ## Install locations diff --git a/internal/cli/uninstall.go b/internal/cli/uninstall.go index ab1875e..dcb6488 100644 --- a/internal/cli/uninstall.go +++ b/internal/cli/uninstall.go @@ -8,11 +8,6 @@ import ( // uninstallOptions holds flags specific to the uninstall command. type uninstallOptions struct { - // Extension uninstalls the debugger extension. - Extension bool - // Interpreter uninstalls a debugger interpreter (optionally a specific - // version given as a positional argument). - Interpreter bool // ZTS selects the thread-safe variant when a version is given. ZTS bool } @@ -22,10 +17,11 @@ func newUninstallCmd() *cobra.Command { cmd := &cobra.Command{ Use: "uninstall [version]", - Short: "Uninstall the interpreter or extension, restoring any backup", - Long: "Remove a debugger interpreter (optionally a specific version) or the\n" + - "debugger extension. If a backup of a previously replaced interpreter exists,\n" + - "it is restored.", + Short: "Uninstall the debugger, restoring any backup", + Long: "Remove the installed debugger. The interpreter and the extension are never\n" + + "installed at once, so the kind is detected automatically; pass a version to\n" + + "select a specific installed interpreter variant. If a backup of a previously\n" + + "replaced interpreter exists, it is restored.", Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { var version string @@ -37,14 +33,11 @@ func newUninstallCmd() *cobra.Command { AssumeYes: globalOpts.Yes, Out: cmd.OutOrStdout(), In: cmd.InOrStdin(), - }, opts.Interpreter, opts.Extension, version, opts.ZTS) + }, version, opts.ZTS) }, } - f := cmd.Flags() - f.BoolVarP(&opts.Extension, "extension", "e", false, "uninstall the debugger extension") - f.BoolVarP(&opts.Interpreter, "interpreter", "i", false, "uninstall the debugger interpreter") - f.BoolVarP(&opts.ZTS, "zts", "z", false, "select the thread-safe (ZTS) variant of the given version") + cmd.Flags().BoolVarP(&opts.ZTS, "zts", "z", false, "select the thread-safe (ZTS) variant of the given version") return cmd } diff --git a/internal/cli/update.go b/internal/cli/update.go index 08f52e6..213cc92 100644 --- a/internal/cli/update.go +++ b/internal/cli/update.go @@ -6,23 +6,13 @@ import ( "github.com/spf13/cobra" ) -// updateOptions holds flags specific to the update command. -type updateOptions struct { - // Extension updates the installed debugger extension. - Extension bool - // Interpreter updates the active debugger interpreter. - Interpreter bool -} - func newUpdateCmd() *cobra.Command { - opts := &updateOptions{} - cmd := &cobra.Command{ Use: "update", - Short: "Update the installed interpreter or extension to the latest release", - Long: "Re-install the currently active interpreter (or the extension) against the\n" + - "latest release. Use --extension or --interpreter to disambiguate when both\n" + - "are installed.", + Short: "Update the installed debugger to the latest release", + Long: "Re-install whatever is installed — the active interpreter or the extension —\n" + + "against the latest release. The two are never installed at once, so the kind\n" + + "is detected automatically.", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { return installer.Update(cmd.Context(), installer.Options{ @@ -30,13 +20,9 @@ func newUpdateCmd() *cobra.Command { AssumeYes: globalOpts.Yes, Out: cmd.OutOrStdout(), In: cmd.InOrStdin(), - }, opts.Interpreter, opts.Extension) + }) }, } - f := cmd.Flags() - f.BoolVarP(&opts.Extension, "extension", "e", false, "update the debugger extension") - f.BoolVarP(&opts.Interpreter, "interpreter", "i", false, "update the debugger interpreter") - return cmd } diff --git a/internal/installer/backup.go b/internal/installer/backup.go index 1ae765b..087b4d9 100644 --- a/internal/installer/backup.go +++ b/internal/installer/backup.go @@ -46,6 +46,9 @@ func backupExisting(srcPath, backupDir, key string) (string, error) { // restoreBackup moves a backup back to its original path, preserving a symlink as // a symlink whether it moves (rename) or falls back to copy across filesystems. +// The restored file keeps the backup's own permissions (which mirror the original +// file's mode — see backupExisting/saveIniBackup), so a rename and the copy +// fallback yield the same mode. func restoreBackup(backupPath, originalPath string) error { if err := os.MkdirAll(filepath.Dir(originalPath), 0o755); err != nil { return err @@ -53,12 +56,31 @@ func restoreBackup(backupPath, originalPath string) error { if err := os.Rename(backupPath, originalPath); err == nil { return nil } - if err := copyNode(backupPath, originalPath, 0o755); err != nil { + fi, err := os.Lstat(backupPath) + if err != nil { + return err + } + if err := copyNode(backupPath, originalPath, fi.Mode().Perm()); err != nil { return err } return os.Remove(backupPath) } +// copyBackup restores a backup to its original path by COPYING it (the backup file +// is left in place, so the operation is retryable until a caller deletes it), +// preserving the backup's own permissions (which mirror the original file's mode — +// see saveIniBackup). A symlink is recreated as a symlink. +func copyBackup(backupPath, originalPath string) error { + fi, err := os.Lstat(backupPath) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(originalPath), 0o755); err != nil { + return err + } + return copyNode(backupPath, originalPath, fi.Mode().Perm()) +} + // copyNode copies src to dst for the cross-filesystem move fallbacks. A symlink is // recreated as a symlink (its target is not dereferenced); a regular file has its // contents copied with perm. diff --git a/internal/installer/cleanup_test.go b/internal/installer/cleanup_test.go index a6f9fa5..f3e85dc 100644 --- a/internal/installer/cleanup_test.go +++ b/internal/installer/cleanup_test.go @@ -72,7 +72,7 @@ func TestUninstallRemovesEmptyRoot(t *testing.T) { } if err := Uninstall(context.Background(), Options{Scope: platform.User, Out: &bytes.Buffer{}, Env: &env}, - false, false, "", false); err != nil { + "", false); err != nil { t.Fatalf("uninstall: %v", err) } if _, err := os.Stat(root); !os.IsNotExist(err) { diff --git a/internal/installer/extension.go b/internal/installer/extension.go index e292cbf..8c15a2e 100644 --- a/internal/installer/extension.go +++ b/internal/installer/extension.go @@ -218,20 +218,10 @@ func stripXdebugFromExisting(existing *php.Info, backupDir string, rb *rollback, // first install), the prior backup is kept, since it holds the user's true // original contents. Returns fresh unchanged when there is nothing to preserve. func preserveBackups(prior *manifest.Extension, fresh []manifest.FileBackup) []manifest.FileBackup { - if prior == nil || len(prior.ConfigBackups) == 0 { + if prior == nil { return fresh } - freshByPath := make(map[string]bool, len(fresh)) - for _, b := range fresh { - freshByPath[b.OriginalPath] = true - } - merged := append([]manifest.FileBackup(nil), fresh...) - for _, b := range prior.ConfigBackups { - if !freshByPath[b.OriginalPath] { - merged = append(merged, b) - } - } - return merged + return mergeFileBackups(fresh, prior.ConfigBackups) } // resolveExtensionDir returns an absolute extension directory. PHP sometimes diff --git a/internal/installer/extension_test.go b/internal/installer/extension_test.go index b963e7b..5a3eda3 100644 --- a/internal/installer/extension_test.go +++ b/internal/installer/extension_test.go @@ -236,6 +236,76 @@ func TestInstallExtensionSkipsOwnInterpreter(t *testing.T) { } } +// Regression: installing the interpreter after the extension must remove the +// extension (the interpreter has the debugger built in), so the two are never both +// recorded. Otherwise uninstall would wrongly report an ambiguity ("both an +// interpreter and an extension are installed"). +func TestInstallInterpreterRemovesInstalledExtension(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("fake php is a /bin/sh script") + } + home := t.TempDir() + extDir := t.TempDir() + iniDir := t.TempDir() + scanDir := filepath.Join(iniDir, "conf.d") + loadedFile := filepath.Join(iniDir, "php.ini") + const originalIni = "zend_extension=xdebug.so\nmemory_limit=100M\n" + if err := os.WriteFile(loadedFile, []byte(originalIni), 0o644); err != nil { + t.Fatal(err) + } + + binDir := filepath.Join(t.TempDir(), "bin") + writeExec(t, filepath.Join(binDir, "php"), + fakeExistingPHPForExt("8.3.7", extDir, loadedFile, scanDir, true)) + t.Setenv("PATH", binDir) + + srv := newFakeReleaseServer(t, fakePHP("8.3.7", true, "", "")) + client := release.NewClient() + client.BaseURL = srv.URL + env := linuxUserEnv(home) + opts := Options{Scope: platform.User, AssumeYes: true, Out: &bytes.Buffer{}, Client: client, Env: &env} + manifestPath := filepath.Join(home, ".local", "share", "php-debugger", "manifest.json") + + // 1. Install the extension. + if err := InstallExtension(context.Background(), opts); err != nil { + t.Fatalf("install extension: %v", err) + } + m, _ := manifest.Load(manifestPath) + if m.Extension == nil { + t.Fatal("extension should be recorded after install") + } + soDst := m.Extension.SoPath + + // 2. Install the interpreter -> must remove the extension. + io := opts + io.PHPVersion = "8.3" + if err := InstallInterpreter(context.Background(), io); err != nil { + t.Fatalf("install interpreter: %v", err) + } + m, _ = manifest.Load(manifestPath) + if m.Extension != nil { + t.Error("interpreter install must remove the extension record") + } + if _, ok := m.Interpreter("8.3"); !ok { + t.Error("interpreter should be recorded") + } + if _, err := os.Stat(soDst); !os.IsNotExist(err) { + t.Errorf("extension .so should have been removed, stat err=%v", err) + } + // Reverting the extension restored the user's original xdebug config. + if got, _ := os.ReadFile(loadedFile); !strings.Contains(string(got), "zend_extension=xdebug.so") { + t.Errorf("extension removal should restore the original xdebug ini, got %q", got) + } + + // 3. Uninstall must work with no kind ambiguity (the real bug). + if err := Uninstall(context.Background(), opts, "", false); err != nil { + t.Fatalf("uninstall after install interpreter should not error, got: %v", err) + } + if _, err := os.Stat(manifestPath); !os.IsNotExist(err) { + t.Errorf("full uninstall should remove the manifest, stat err=%v", err) + } +} + // mutableExtServer serves a release with a single extension asset whose tag can // change between requests, to simulate a newer release becoming available. type mutableExtServer struct { @@ -315,7 +385,7 @@ func TestUpdateExtensionPreservesXdebugBackup(t *testing.T) { var out bytes.Buffer uo := opts uo.Out = &out - if err := Update(context.Background(), uo, false, true); err != nil { + if err := Update(context.Background(), uo); err != nil { t.Fatalf("Update: %v\n%s", err, out.String()) } @@ -330,7 +400,7 @@ func TestUpdateExtensionPreservesXdebugBackup(t *testing.T) { // End-to-end: uninstall after update must restore the user's original ini. if err := Uninstall(context.Background(), Options{Scope: platform.User, Out: &bytes.Buffer{}, Env: &env}, - false, false, "", false); err != nil { + "", false); err != nil { t.Fatalf("uninstall: %v", err) } got, err := os.ReadFile(loadedFile) @@ -342,6 +412,64 @@ func TestUpdateExtensionPreservesXdebugBackup(t *testing.T) { } } +// Regression: uninstalling the extension must restore the modified ini file with +// its original permissions. The backup is created via os.CreateTemp (mode 0600), +// so a naive restore (rename/copy) clamped a world-readable php.ini down to +// owner-only, and php running as another user (e.g. php-fpm as www-data) could no +// longer read it. +func TestUninstallExtensionRestoresIniPermissions(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("fake php is a /bin/sh script") + } + home := t.TempDir() + extDir := t.TempDir() + iniDir := t.TempDir() + scanDir := filepath.Join(iniDir, "conf.d") + loadedFile := filepath.Join(iniDir, "php.ini") + const origMode os.FileMode = 0o644 + if err := os.WriteFile(loadedFile, + []byte("zend_extension=xdebug.so\nmemory_limit=100M\n"), origMode); err != nil { + t.Fatal(err) + } + // os.WriteFile is subject to umask; force the exact mode we assert on. + if err := os.Chmod(loadedFile, origMode); err != nil { + t.Fatal(err) + } + + binDir := filepath.Join(t.TempDir(), "bin") + writeExec(t, filepath.Join(binDir, "php"), + fakeExistingPHPForExt("8.3.7", extDir, loadedFile, scanDir, true)) + t.Setenv("PATH", binDir) + + srv := newFakeReleaseServer(t, "unused") + client := release.NewClient() + client.BaseURL = srv.URL + env := linuxUserEnv(home) + opts := Options{Scope: platform.User, AssumeYes: true, Out: &bytes.Buffer{}, Client: client, Env: &env} + + if err := InstallExtension(context.Background(), opts); err != nil { + t.Fatalf("InstallExtension: %v", err) + } + // Sanity: install backed up the ini (there was xdebug to strip). + m, _ := manifest.Load(filepath.Join(home, ".local", "share", "php-debugger", "manifest.json")) + if m.Extension == nil || len(m.Extension.ConfigBackups) == 0 { + t.Fatalf("install should record an ini backup, got %+v", m.Extension) + } + + if err := Uninstall(context.Background(), Options{Scope: platform.User, Out: &bytes.Buffer{}, Env: &env}, + "", false); err != nil { + t.Fatalf("uninstall: %v", err) + } + + fi, err := os.Stat(loadedFile) + if err != nil { + t.Fatal(err) + } + if got := fi.Mode().Perm(); got != origMode { + t.Errorf("restored ini mode = %o, want %o (php as another user could not read it)", got, origMode) + } +} + // Regression: a php that reports a relative extension_dir (e.g. Windows' "ext") // must have the .so installed under the PHP install dir, with an absolute loader // path — not relative to the installer's working directory. diff --git a/internal/installer/iniconfig.go b/internal/installer/iniconfig.go index 0915c59..a851108 100644 --- a/internal/installer/iniconfig.go +++ b/internal/installer/iniconfig.go @@ -121,40 +121,109 @@ func saveIniBackup(backupDir, originalPath string, data []byte) (string, error) os.Remove(f.Name()) return "", fmt.Errorf("backing up %s: %w", originalPath, err) } + // CreateTemp makes the backup 0600. restoreBackup reproduces the backup file's + // own mode on uninstall (a rename carries it over verbatim), so align the backup + // with the original ini's permissions — otherwise restoring would clamp a + // world-readable php.ini down to owner-only and php running as another user + // (e.g. php-fpm as www-data) could no longer read it. + if fi, err := os.Stat(originalPath); err == nil { + if err := os.Chmod(f.Name(), fi.Mode().Perm()); err != nil { + os.Remove(f.Name()) + return "", fmt.Errorf("backing up %s: %w", originalPath, err) + } + } return f.Name(), nil } -// copyConfig copies the existing interpreter's ini files into the new -// interpreter's compiled-in config path (so the new php loads the same -// configuration), sanitizing each on the way. Returns the destination files. +// copyConfig makes the new interpreter load the same (sanitized) configuration as +// the one it replaces. Config files whose destination differs from their source +// are copied into the new interpreter's compiled-in config path and returned as +// written files (uninstall deletes them). Files whose destination IS their source +// — which happens when the new interpreter's compiled-in config path is the +// existing php's own directory (e.g. both report /opt/homebrew/etc/php/8.5) — are +// instead sanitized in place with their originals backed up, and returned as +// FileBackups (uninstall restores them). Writing a "copy" over such a file would +// silently mutate the user's real config; deleting it on uninstall would destroy +// their configuration and prune their directory. // // Other (non-xdebug) extension loaders are commented out only when the new // 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. -func copyConfig(existing, target *php.Info, rb *rollback, opts Options) ([]string, error) { - pairs := interpreterConfigPairs(existing, target) - if len(pairs) == 0 { +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 { if target.Ini.ConfigPath == "" && target.Ini.ScanDir == "" { opts.logf("Note: the interpreter reports no config path; skipping ini copy.") } - return nil, nil + return nil, nil, nil } sameABI := existing.Series == target.Series && existing.ZTS == target.ZTS if sameABI { opts.logf(" (same PHP %s %s as before; keeping existing extension loaders)", target.Series, threading(target.ZTS)) } + // The debugger loader is always stripped: the interpreter has it built in, so // loading a standalone php-debugger extension on top would double-register it. - // No backupDir: this writes copies into the new interpreter's config path and - // never modifies the user's originals, so there is nothing to restore later. - written, _, err := rewriteIniFiles(pairs, iniRewriteOptions{ + written, _, err := rewriteIniFiles(copyPairs, iniRewriteOptions{ + commentOtherLoaders: !sameABI, + stripDebuggerLoader: true, + }, rb, opts) + if err != nil { + return written, nil, err + } + + // In-place edits of the user's own config: sanitize (disabling the incompatible + // xdebug) but back up the originals so uninstall can restore them. Only files the + // rules actually change are touched and recorded. + if len(sharedPairs) > 0 { + opts.logf("The interpreter shares the existing PHP's config directory (%s); "+ + "disabling incompatible extensions there (restored on uninstall).", target.Ini.ConfigPath) + } + _, backups, err := rewriteIniFiles(sharedPairs, iniRewriteOptions{ commentOtherLoaders: !sameABI, stripDebuggerLoader: true, + skipUnchanged: true, + backupDir: backupDir, }, rb, opts) - return written, err + if err != nil { + return written, backups, err + } + return written, backups, nil +} + +// preserveConfig combines the config bookkeeping captured this run with a prior +// interpreter record's, so a reinstall/update — which runs over our own active php +// and therefore re-copies nothing — keeps the entries uninstall needs. A freshly +// derived set of copied files wins; otherwise the prior list is kept. In-place ini +// backups are merged by file, freshly-captured ones winning. +func preserveConfig(prev manifest.Interpreter, files []string, backups []manifest.FileBackup) ([]string, []manifest.FileBackup) { + if len(files) == 0 { + files = prev.ConfigFiles + } + return files, mergeFileBackups(backups, prev.ConfigBackups) +} + +// mergeFileBackups returns fresh plus any prior backup for a file fresh did not +// cover (matched by OriginalPath); a freshly-captured backup wins for its own file, +// since it holds that file's true pre-edit contents from this run. +func mergeFileBackups(fresh, prior []manifest.FileBackup) []manifest.FileBackup { + if len(prior) == 0 { + return fresh + } + freshByPath := make(map[string]bool, len(fresh)) + for _, b := range fresh { + freshByPath[b.OriginalPath] = true + } + merged := append([]manifest.FileBackup(nil), fresh...) + for _, b := range prior { + if !freshByPath[b.OriginalPath] { + merged = append(merged, b) + } + } + return merged } // sanitizeInPlace applies the xdebug ini rules to the given existing ini files @@ -167,6 +236,36 @@ func sanitizeInPlace(files []string, backupDir string, rb *rollback, opts Option return backups, err } +// sanitizeOwnConfig disables extensions the freshly installed interpreter cannot +// load from its OWN compiled-in config path. A self-contained build cannot load +// foreign .so files, and these builds report a version-specific config path (e.g. +// /opt/homebrew/etc/php/8.5) that is often a Homebrew php's own directory, holding +// an xdebug loader the interpreter would choke on. It edits those files in place, +// backing up the originals so uninstall restores them. +// +// This runs when no foreign php was detected to copy configuration from — most +// importantly when switching to a new version while our own interpreter is active +// — because the interpreter still reads whatever lives at its config path. Loaders +// are always commented (the build cannot load any foreign extension); only files +// the rules actually change are touched and recorded. +func sanitizeOwnConfig(info *php.Info, backupDir string, rb *rollback, opts Options) ([]manifest.FileBackup, error) { + var files []string + if info.Ini.LoadedFile != "" { + files = append(files, info.Ini.LoadedFile) + } + files = append(files, info.Ini.AdditionalFiles...) + if len(files) == 0 { + return nil, nil + } + _, backups, err := rewriteIniFiles(inPlacePairs(files), iniRewriteOptions{ + commentOtherLoaders: true, + stripDebuggerLoader: true, + skipUnchanged: true, + backupDir: backupDir, + }, rb, opts) + return backups, err +} + // interpreterConfigPairs maps the existing main php.ini to the new interpreter's // ConfigPath, and each additional .ini to its ScanDir (by base name). func interpreterConfigPairs(existing, target *php.Info) []configPair { @@ -188,6 +287,34 @@ func interpreterConfigPairs(existing, target *php.Info) []configPair { return pairs } +// splitInPlacePairs partitions config pairs into those that copy to a new location +// (dst != src) and those whose destination resolves to the source file itself (the +// new interpreter shares the existing php's config directory). The two groups are +// handled differently: copies are written and deleted on uninstall; in-place edits +// are backed up and restored on uninstall. +func splitInPlacePairs(pairs []configPair) (copies, inPlace []configPair) { + for _, pr := range pairs { + if sameFile(pr.src, pr.dst) { + inPlace = append(inPlace, pr) + } else { + copies = append(copies, pr) + } + } + return copies, inPlace +} + +// sameFile reports whether two paths refer to the same file. It compares cleaned +// paths and, when both exist, falls back to os.SameFile so symlinked or otherwise +// aliased paths still match. +func sameFile(a, b string) bool { + if filepath.Clean(a) == filepath.Clean(b) { + return true + } + fa, err1 := os.Stat(a) + fb, err2 := os.Stat(b) + return err1 == nil && err2 == nil && os.SameFile(fa, fb) +} + // inPlacePairs turns a list of files into src==dst pairs for in-place rewriting. func inPlacePairs(files []string) []configPair { pairs := make([]configPair, 0, len(files)) diff --git a/internal/installer/install.go b/internal/installer/install.go index 22e442f..d5a1c63 100644 --- a/internal/installer/install.go +++ b/internal/installer/install.go @@ -115,6 +115,13 @@ func InstallInterpreter(ctx context.Context, opts Options) error { return err } + // Loaded up front so we can enforce the interpreter/extension invariant below + // and reused for recording at the end. + m, err := manifest.Load(layout.ManifestPath()) + if err != nil { + return err + } + client := opts.Client if client == nil { client = release.NewClient() @@ -189,6 +196,32 @@ func InstallInterpreter(ctx context.Context, opts Options) error { return fmt.Errorf("querying downloaded interpreter: %w", err) } + // Enforce the invariant that the interpreter and the extension are never both + // installed: the interpreter has the debugger compiled in, so a standalone + // extension is redundant and its loader would double-register. Now that the + // interpreter is proven to run on this host (smoke test passed) but before any + // system change, remove any installed extension — reverting its ini edits so the + // user's original config (e.g. xdebug) is restored and then re-derived cleanly by + // the interpreter's own config handling below. Committed immediately so that if + // the install later rolls back, disk and manifest still agree the extension is + // gone. If existing is the php the extension modified, re-query it so its ini + // paths reflect the reverted state (the extension's loader file is now gone). + if m.Extension != nil { + opts.logf("Removing the previously installed debugger extension (the interpreter includes it).") + if err := revertExtension(opts, m.Extension); err != nil { + return fmt.Errorf("removing the previously installed extension: %w", err) + } + m.ClearExtension() + if err := m.Save(layout.ManifestPath()); err != nil { + return fmt.Errorf("saving manifest after removing extension: %w", err) + } + if existing != nil { + if refreshed, qErr := php.Query(ctx, existing.Path); qErr == nil { + existing = refreshed + } + } + } + rb := &rollback{} // --- place the binary into the versioned directory --- @@ -213,15 +246,28 @@ func InstallInterpreter(ctx context.Context, opts Options) error { rb.add(func() error { return os.RemoveAll(versionDir) }) } - // --- copy the existing interpreter's ini config into the new one --- + // --- sanitize the ini config the new interpreter will load --- var configFiles []string + var configBackups []manifest.FileBackup 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). opts.logf("Copying existing PHP configuration (removing xdebug) ...") - configFiles, err = copyConfig(existing, info, rb, opts) + configFiles, configBackups, err = copyConfig(existing, info, layout.BackupsDir(), rb, opts) if err != nil { rb.run() return fmt.Errorf("copying ini configuration; 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 + // compiled-in config path, which may hold a same-version Homebrew xdebug it + // cannot load. Disable such loaders in place, backed up for restore. + configBackups, err = sanitizeOwnConfig(info, layout.BackupsDir(), rb, opts) + if err != nil { + rb.run() + return fmt.Errorf("sanitizing interpreter config; rolled back: %w", err) + } } // --- back up and replace an existing interpreter at its location --- @@ -291,22 +337,26 @@ func InstallInterpreter(ctx context.Context, opts Options) error { } // --- record in the manifest --- - m, err := manifest.Load(layout.ManifestPath()) - if err != nil { - rb.run() - return err - } key := platform.VersionDirName(series, opts.ZTS) m.InstallRoot = layout.Root m.BinDir = linkDir + // Carry the config bookkeeping forward when this run did not re-derive it. A + // reinstall/update runs over our own already-active interpreter, so no foreign + // php is detected and copyConfig is skipped — but the prior record still holds + // the copied files and the in-place ini backups uninstall needs. preserveConfig + // 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) + } m.SetInterpreter(key, manifest.Interpreter{ - Series: series, - PHPVersion: info.Version, - ZTS: opts.ZTS, - ReleaseTag: rel.TagName, - Dir: versionDir, - InstalledAt: opts.clock(), - ConfigFiles: configFiles, + Series: series, + PHPVersion: info.Version, + ZTS: opts.ZTS, + ReleaseTag: rel.TagName, + Dir: versionDir, + InstalledAt: opts.clock(), + ConfigFiles: configFiles, + ConfigBackups: configBackups, }) m.SetActive(key) for i, b := range backups { diff --git a/internal/installer/install_test.go b/internal/installer/install_test.go index b2e12ec..6239978 100644 --- a/internal/installer/install_test.go +++ b/internal/installer/install_test.go @@ -7,6 +7,7 @@ import ( "net/http" "net/http/httptest" "os" + "os/exec" "path/filepath" "runtime" "strings" @@ -53,6 +54,30 @@ exit 0 `, version, modules, cfgDir, scanDir, version, series) } +// fakePHPLoadingConfig is like fakePHP but reports a real loaded php.ini and an +// additional parsed .ini file (via `--ini`), so tests can exercise sanitizing the +// interpreter's OWN compiled-in config. It always reports the debugger module. +func fakePHPLoadingConfig(version, loadedFile, scanDir, additional string) string { + series := version + if i := strings.LastIndex(version, "."); i >= 0 { + series = version[:i] + } + return fmt.Sprintf(`#!/bin/sh +case "$1" in + -v) echo "PHP %s (cli) (built: Jan 1 2026) (NTS)" ;; + -m) printf '[PHP Modules]\nCore\ndate\nphp_debugger\n' ;; + --ini) + echo "Configuration File (php.ini) Path: \"%s\"" + echo "Loaded Configuration File: \"%s\"" + echo "Scan for additional .ini files in: \"%s\"" + echo "Additional .ini files parsed: \"%s\"" ;; + -r) printf 'version=%s\nseries=%s\nzts=0\nextension_dir=/fake/ext\n' ;; + *) : ;; +esac +exit 0 +`, version, filepath.Dir(loadedFile), loadedFile, scanDir, additional, version, series) +} + const fakeSO = "FAKE-DEBUGGER-SO-BYTES" // newFakeReleaseServer serves a latest-release payload with both an interpreter @@ -185,7 +210,7 @@ func TestInstallInterpreterBacksUpUndetectedInterpreterAtDest(t *testing.T) { // Uninstall restores the foreign php byte-for-byte. if err := Uninstall(context.Background(), Options{Scope: platform.User, Out: &bytes.Buffer{}, Env: &env}, - false, false, "", false); err != nil { + "", false); err != nil { t.Fatalf("uninstall: %v", err) } if isLink, _ := platform.IsSymlink(foreign); isLink { @@ -245,7 +270,7 @@ func TestInstallInterpreterBacksUpForeignSymlinkAtDest(t *testing.T) { // Uninstall restores the user's foreign symlink, pointing at their php. if err := Uninstall(context.Background(), Options{Scope: platform.User, Out: &bytes.Buffer{}, Env: &env}, - false, false, "", false); err != nil { + "", false); err != nil { t.Fatalf("uninstall: %v", err) } if isLink, _ := isSymlinkNode(link); !isLink { @@ -432,6 +457,363 @@ func TestPreserveDestinationBacksUpForeignSymlink(t *testing.T) { } } +// Regression: these self-contained builds report the existing PHP's own config +// directory as their compiled-in config path (e.g. /opt/homebrew/etc/php/8.5). +// The interpreter install must disable the incompatible xdebug there IN PLACE but +// back the file up — so the self-contained php stops erroring on the foreign +// zend_extension — and uninstall must RESTORE the user's original config (never +// delete it or prune their directory, as an earlier version did). +func TestInstallInterpreterSharedConfigDirInPlace(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("fake php is a /bin/sh script") + } + home := t.TempDir() + + // The existing php's config lives here; the NEW interpreter reports the very + // same directory as its config path/scan dir (the real-world collision). + sharedCfg := t.TempDir() + sharedScan := filepath.Join(sharedCfg, "conf.d") + srv := newFakeReleaseServer(t, fakePHP("8.3.7", true, sharedCfg, sharedScan)) + + existBin := filepath.Join(t.TempDir(), "bin") + loadedFile := filepath.Join(sharedCfg, "php.ini") + xdebugIni := filepath.Join(sharedScan, "xdebug.ini") + writeExec(t, filepath.Join(existBin, "php"), + existingPHPScript("8.3.4", loadedFile, sharedScan, xdebugIni)) + if err := os.MkdirAll(sharedScan, 0o755); err != nil { + t.Fatal(err) + } + const phpIni = "memory_limit=128M\n" + const xdebugContent = "zend_extension=/opt/homebrew/lib/php/xdebug.so\n" + if err := os.WriteFile(loadedFile, []byte(phpIni), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(xdebugIni, []byte(xdebugContent), 0o644); err != nil { + t.Fatal(err) + } + 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%s", err, out.String()) + } + + // Install disabled the incompatible xdebug loader in the shared file... + if got, _ := os.ReadFile(xdebugIni); strings.Contains(string(got), "zend_extension") { + t.Errorf("shared xdebug.ini should have its loader stripped, got %q", got) + } + // ...backing it up (recorded as ConfigBackups, not deletable ConfigFiles). + 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.ConfigFiles) != 0 { + t.Errorf("user's own files must not be recorded as copied ConfigFiles, got %v", it.ConfigFiles) + } + if len(it.ConfigBackups) == 0 { + t.Fatal("in-place sanitized file must be recorded as a ConfigBackup for restore") + } + + // Uninstall must restore the user's original config, not delete it or prune the + // directory. + if err := Uninstall(context.Background(), Options{Scope: platform.User, Out: &bytes.Buffer{}, Env: &env}, + "", false); err != nil { + t.Fatalf("uninstall: %v", err) + } + got, err := os.ReadFile(xdebugIni) + if err != nil { + t.Fatalf("uninstall deleted the user's xdebug.ini: %v", err) + } + if string(got) != xdebugContent { + t.Errorf("uninstall did not restore xdebug.ini: got %q, want %q", got, xdebugContent) + } + if fi, err := os.Stat(xdebugIni); err != nil || fi.Mode().Perm() != 0o644 { + t.Errorf("restored xdebug.ini mode = %v (err %v), want 0644", fi.Mode().Perm(), err) + } +} + +// Regression: `switch ` (and any install with no foreign php on PATH, e.g. +// while our own interpreter is active) still installs an interpreter whose +// compiled-in config path may hold a same-version Homebrew xdebug it cannot load. +// The install must sanitize that own config in place (backed up), or the new +// interpreter errors trying to load the foreign zend_extension on every run. +func TestInstallInterpreterSanitizesOwnConfigNoForeignPHP(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("fake php is a /bin/sh script") + } + isolatePATH(t) // no foreign php on PATH -> existing == nil + home := t.TempDir() + + // The new interpreter's own compiled-in config path holds a real xdebug loader. + ownCfg := t.TempDir() + ownScan := filepath.Join(ownCfg, "conf.d") + loadedFile := filepath.Join(ownCfg, "php.ini") + xdebugIni := filepath.Join(ownScan, "xdebug.ini") + if err := os.MkdirAll(ownScan, 0o755); err != nil { + t.Fatal(err) + } + const xdebugContent = "zend_extension=/opt/homebrew/lib/php/xdebug.so\n" + if err := os.WriteFile(loadedFile, []byte("memory_limit=128M\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(xdebugIni, []byte(xdebugContent), 0o644); err != nil { + t.Fatal(err) + } + + srv := newFakeReleaseServer(t, fakePHPLoadingConfig("8.3.7", loadedFile, ownScan, xdebugIni)) + 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%s", err, out.String()) + } + + // The incompatible xdebug loader must be disabled in the interpreter's own config. + if got, _ := os.ReadFile(xdebugIni); strings.Contains(string(got), "zend_extension") { + t.Errorf("own xdebug.ini loader should be stripped, got %q", got) + } + // ...and recorded as a restorable backup, not a deletable copied file. + 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.ConfigFiles) != 0 { + t.Errorf("own config must not be recorded as copied ConfigFiles, got %v", it.ConfigFiles) + } + if len(it.ConfigBackups) == 0 { + t.Fatal("sanitized own config must be recorded as a ConfigBackup for restore") + } + + // Uninstall restores the original config, leaving the directory intact. + if err := Uninstall(context.Background(), Options{Scope: platform.User, Out: &bytes.Buffer{}, Env: &env}, + "", false); err != nil { + t.Fatalf("uninstall: %v", err) + } + if got, _ := os.ReadFile(xdebugIni); string(got) != xdebugContent { + t.Errorf("uninstall did not restore xdebug.ini: got %q, want %q", got, xdebugContent) + } +} + +// End-to-end for the reported flow: a Homebrew php 8.5 with xdebug is on PATH; +// `install -p 8.3` replaces it, then `switch 8.5` installs 8.5 whose compiled-in +// config path is the Homebrew 8.5 config (xdebug there gets disabled in place). +// Uninstalling must then restore everything: uninstalling active 8.5 brings its +// xdebug config back and falls to 8.3; uninstalling 8.3 restores the original +// Homebrew php. This is the specific concern — does the new own-config handling +// uninstall correctly across multiple interpreters. +func TestInstallSwitchUninstallLifecycle(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("fake php is a /bin/sh script") + } + home := t.TempDir() + + // Homebrew 8.5 config (shared by the real php and, later, our 8.5 interpreter). + hb85 := t.TempDir() + hb85Scan := filepath.Join(hb85, "conf.d") + hb85Php := filepath.Join(hb85, "php.ini") + hb85Xdebug := filepath.Join(hb85Scan, "xdebug.ini") + if err := os.MkdirAll(hb85Scan, 0o755); err != nil { + t.Fatal(err) + } + const xdebugContent = "zend_extension=/opt/homebrew/lib/php/xdebug.so\n" + if err := os.WriteFile(hb85Php, []byte("memory_limit=256M\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(hb85Xdebug, []byte(xdebugContent), 0o644); err != nil { + t.Fatal(err) + } + + // The 8.3 interpreter reports its own (separate) config path. + new83 := filepath.Join(t.TempDir(), "etc83") + new83Scan := filepath.Join(new83, "conf.d") + + // Release serves both an 8.3 and an 8.5 interpreter asset. The 8.5 fake reports + // the Homebrew 8.5 config as its own compiled-in config (the real collision). + php83 := fakePHP("8.3.7", true, new83, new83Scan) + php85 := fakePHPLoadingConfig("8.5.1", hb85Php, hb85Scan, hb85Xdebug) + var srv *httptest.Server + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/repos/php-debugger/php-debugger/releases/latest": + fmt.Fprintf(w, `{"tag_name":"9.9.9","assets":[ + {"name":"php-php8.3-nts-linux-x86_64","browser_download_url":%q}, + {"name":"php-php8.5-nts-linux-x86_64","browser_download_url":%q} + ]}`, srv.URL+"/dl/83", srv.URL+"/dl/85") + case "/dl/83": + w.Write([]byte(php83)) + case "/dl/85": + w.Write([]byte(php85)) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + + // The Homebrew php 8.5 on PATH (with xdebug), to be replaced. + existBin := filepath.Join(t.TempDir(), "bin") + existPhp := filepath.Join(existBin, "php") + writeExec(t, existPhp, existingPHPScript("8.5.1", hb85Php, hb85Scan, hb85Xdebug)) + t.Setenv("PATH", existBin) + + client := release.NewClient() + client.BaseURL = srv.URL + env := linuxUserEnv(home) + base := func() Options { + return Options{Scope: platform.User, AssumeYes: true, Out: &bytes.Buffer{}, Client: client, Env: &env} + } + root := filepath.Join(home, ".local", "share", "php-debugger") + manifestPath := filepath.Join(root, "manifest.json") + + // 1. install -p 8.3 (replaces Homebrew 8.5). 8.5's own config is untouched here. + o := base() + o.PHPVersion = "8.3" + if err := InstallInterpreter(context.Background(), o); err != nil { + t.Fatalf("install 8.3: %v", err) + } + if got, _ := os.ReadFile(hb85Xdebug); string(got) != xdebugContent { + t.Fatalf("installing 8.3 must not touch the 8.5 config, got %q", got) + } + + // 2. switch 8.5 (installs it; our 8.3 is active, so existing == nil). This must + // disable the incompatible xdebug in the shared 8.5 config, backed up. + o = base() + o.PHPVersion = "8.5" + if err := Switch(context.Background(), o); err != nil { + t.Fatalf("switch 8.5: %v", err) + } + if got, _ := os.ReadFile(hb85Xdebug); strings.Contains(string(got), "zend_extension") { + t.Fatalf("switch 8.5 should disable xdebug in the shared config, got %q", got) + } + m, _ := manifest.Load(manifestPath) + if m.Active() != "8.5" { + t.Fatalf("active = %q, want 8.5", m.Active()) + } + if it, _ := m.Interpreter("8.5"); len(it.ConfigBackups) == 0 { + t.Fatalf("8.5 must record a ConfigBackup for its in-place edit") + } + + // 3. uninstall active 8.5 -> restores its xdebug config, falls back to 8.3. + if err := Uninstall(context.Background(), base(), "8.5", false); err != nil { + t.Fatalf("uninstall 8.5: %v", err) + } + if got, _ := os.ReadFile(hb85Xdebug); string(got) != xdebugContent { + t.Errorf("uninstall 8.5 did not restore its xdebug config: got %q", got) + } + m, _ = manifest.Load(manifestPath) + if m.Active() != "8.3" { + t.Errorf("after removing 8.5, active = %q, want 8.3", m.Active()) + } + if _, err := os.Stat(filepath.Join(root, "8.3", "bin", "php")); err != nil { + t.Errorf("8.3 should still be installed: %v", err) + } + + // 4. uninstall 8.3 (last one) -> restores the original Homebrew php. + if err := Uninstall(context.Background(), base(), "8.3", false); err != nil { + t.Fatalf("uninstall 8.3: %v", err) + } + // The original Homebrew php is back at its path and reports its version (i.e. + // the real interpreter, not a dangling symlink into our removed root). + if v, err := exec.Command(existPhp, "-v").CombinedOutput(); err != nil || !strings.Contains(string(v), "PHP 8.5.1") { + t.Errorf("original Homebrew php not restored/runnable: out=%q err=%v", v, err) + } + // The Homebrew 8.5 config remains intact (xdebug still enabled for it). + if got, _ := os.ReadFile(hb85Xdebug); string(got) != xdebugContent { + t.Errorf("Homebrew 8.5 config should still have xdebug, got %q", got) + } + // Full uninstall removed our install root. + if _, err := os.Stat(root); !os.IsNotExist(err) { + t.Errorf("install root should be gone after full uninstall, err=%v", err) + } +} + +// Regression: an update reinstalls over our own already-active interpreter, so no +// foreign php is detected and copyConfig is skipped. The in-place config backup +// captured at first install must be carried forward (preserveConfig), or a later +// uninstall could no longer restore the user's xdebug. +func TestUpdateInterpreterPreservesSharedConfigBackup(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("fake php is a /bin/sh script") + } + home := t.TempDir() + sharedCfg := t.TempDir() + sharedScan := filepath.Join(sharedCfg, "conf.d") + loadedFile := filepath.Join(sharedCfg, "php.ini") + xdebugIni := filepath.Join(sharedScan, "xdebug.ini") + + existBin := filepath.Join(t.TempDir(), "bin") + writeExec(t, filepath.Join(existBin, "php"), + existingPHPScript("8.3.4", loadedFile, sharedScan, xdebugIni)) + if err := os.MkdirAll(sharedScan, 0o755); err != nil { + t.Fatal(err) + } + const xdebugContent = "zend_extension=/opt/homebrew/lib/php/xdebug.so\n" + if err := os.WriteFile(loadedFile, []byte("memory_limit=128M\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(xdebugIni, []byte(xdebugContent), 0o644); err != nil { + t.Fatal(err) + } + // After install our symlink replaces the existing php here, so update finds it. + t.Setenv("PATH", existBin) + + ms := newMutableServer(t, "1.0.0", fakePHP("8.3.7", true, sharedCfg, sharedScan)) + client := release.NewClient() + client.BaseURL = ms.URL + env := linuxUserEnv(home) + opts := Options{Scope: platform.User, AssumeYes: true, Out: &bytes.Buffer{}, Client: client, Env: &env, PHPVersion: "8.3"} + + if err := InstallInterpreter(context.Background(), opts); err != nil { + t.Fatalf("install: %v", err) + } + manifestPath := filepath.Join(home, ".local", "share", "php-debugger", "manifest.json") + m, _ := manifest.Load(manifestPath) + if it, _ := m.Interpreter("8.3"); len(it.ConfigBackups) == 0 { + t.Fatalf("install should record a ConfigBackup, got %+v", it) + } + + // A newer release appears; update the interpreter. + ms.set("2.0.0", fakePHP("8.3.9", true, sharedCfg, sharedScan)) + var out bytes.Buffer + uo := opts + uo.PHPVersion = "" + uo.Out = &out + if err := Update(context.Background(), uo); err != nil { + t.Fatalf("update: %v\n%s", err, out.String()) + } + + m, _ = manifest.Load(manifestPath) + it, _ := m.Interpreter("8.3") + if it.ReleaseTag != "2.0.0" { + t.Fatalf("interpreter not updated: %+v", it) + } + if len(it.ConfigBackups) == 0 { + t.Fatal("update lost the shared-config backup; uninstall could no longer restore xdebug") + } + + // Uninstall after update must still restore the user's original xdebug config. + if err := Uninstall(context.Background(), Options{Scope: platform.User, Out: &bytes.Buffer{}, Env: &env}, + "", false); err != nil { + t.Fatalf("uninstall: %v", err) + } + if got, _ := os.ReadFile(xdebugIni); string(got) != xdebugContent { + t.Errorf("xdebug not restored after update+uninstall: got %q, want %q", got, xdebugContent) + } +} + func TestInstallInterpreterReplacesExisting(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("fake php is a /bin/sh script") diff --git a/internal/installer/uninstall.go b/internal/installer/uninstall.go index c2eefff..640973e 100644 --- a/internal/installer/uninstall.go +++ b/internal/installer/uninstall.go @@ -14,11 +14,14 @@ import ( "github.com/php-debugger/installer/internal/release" ) -// Uninstall removes an installed interpreter variant or the extension. For an -// interpreter, version selects a specific variant; empty means the active one. -// If removing the active interpreter leaves other variants, one is activated; -// otherwise a backed-up original interpreter is restored (if present). -func Uninstall(ctx context.Context, opts Options, wantInterp, wantExt bool, version string, zts bool) error { +// Uninstall removes the installed interpreter or extension. The two are never +// installed at once (installing the interpreter removes any extension — see +// InstallInterpreter), so the kind is unambiguous and need not be specified. +// version selects a specific interpreter variant; empty means the active one (and +// implies an interpreter). If removing the active interpreter leaves other +// variants, one is activated; otherwise a backed-up original interpreter is +// restored (if present). +func Uninstall(ctx context.Context, opts Options, version string, zts bool) error { env, err := opts.env() if err != nil { return err @@ -32,29 +35,20 @@ func Uninstall(ctx context.Context, opts Options, wantInterp, wantExt bool, vers return err } - hasInterp := len(m.InterpreterKeys()) > 0 - hasExt := m.Extension != nil - - if !wantInterp && !wantExt { - switch { - case version != "": - wantInterp = true - case hasInterp && hasExt: - return errors.New("both an interpreter and an extension are installed; " + - "specify --interpreter or --extension") - case hasInterp: - wantInterp = true - case hasExt: - wantExt = true - default: - return errors.New("nothing installed to uninstall") - } + if err := reconcileInvariant(layout, m, opts); err != nil { + return err } - if wantExt { + // A version always refers to an interpreter variant. Otherwise uninstall + // whichever kind is installed. + switch { + case version != "" || len(m.InterpreterKeys()) > 0: + return uninstallInterpreter(opts, layout, m, env, version, zts) + case m.Extension != nil: return uninstallExtension(opts, layout, m) + default: + return errors.New("nothing installed to uninstall") } - return uninstallInterpreter(opts, layout, m, env, version, zts) } func uninstallInterpreter(opts Options, layout platform.Layout, m *manifest.Manifest, env platform.Env, version string, zts bool) error { @@ -95,6 +89,22 @@ func uninstallInterpreter(opts Options, layout platform.Layout, m *manifest.Mani consumedBackups = cb } + // Restore the existing php's own ini files we sanitized in place at install + // (e.g. bringing back a stripped xdebug). These are the user's real config files + // — living in a directory the new interpreter happened to share — so they are + // restored to their original contents, never deleted. This must precede the + // commit: the ini backups live under the install root, which finalizeManifest + // removes wholesale once nothing is installed. Restore by COPY so a failed commit + // stays retryable (the backup survives), then let the post-commit cleanup below + // drop the backup file along with the other consumed backups. + for _, cb := range it.ConfigBackups { + if err := copyBackup(cb.BackupPath, cb.OriginalPath); err != nil { + return fmt.Errorf("restoring %s: %w", cb.OriginalPath, err) + } + consumedBackups = append(consumedBackups, cb.BackupPath) + opts.logf("Restored %s.", cb.OriginalPath) + } + // Persist the consistent state (variant removed, active reassigned) before the // destructive cleanup, so a later removal failure can only leave orphan files — // never a manifest that points at an already-deleted interpreter directory. @@ -103,9 +113,10 @@ func uninstallInterpreter(opts Options, layout platform.Layout, m *manifest.Mani } // Only now that the manifest no longer references them, delete the backup files - // the restore copied into place. Deferring until after the commit keeps a failed + // the restores copied into place. Deferring until after the commit keeps a failed // save (or a partial restore) recoverable: the backups survive until the manifest - // that would need them for a retry is gone. + // that would need them for a retry is gone. (finalizeManifest may already have + // removed the whole root — and these with it — when nothing remains installed.) for _, p := range consumedBackups { _ = removeIfExists(p) } @@ -192,7 +203,23 @@ func uninstallExtension(opts Options, layout platform.Layout, m *manifest.Manife if ext == nil { return errors.New("no extension installed") } + if err := revertExtension(opts, ext); err != nil { + return err + } + m.ClearExtension() + if err := finalizeManifest(layout, m); err != nil { + return fmt.Errorf("saving manifest: %w", err) + } + opts.logf("Uninstalled the php-debugger extension for php %s.", ext.Series) + return nil +} +// revertExtension undoes an extension install on disk: it removes the loader line, +// deletes the copied .so, and restores the ini files we modified in place (bringing +// back any xdebug we disabled). The restore runs last so it is authoritative over +// the loader removal for a file that held both. It does not touch the manifest — +// callers clear the record and persist. +func revertExtension(opts Options, ext *manifest.Extension) error { if ext.IniPath != "" { if err := removeExtensionLoader(ext.IniPath, ext.SoPath); err != nil { return fmt.Errorf("removing loader from %s: %w", ext.IniPath, err) @@ -203,23 +230,32 @@ func uninstallExtension(opts Options, layout platform.Layout, m *manifest.Manife return fmt.Errorf("removing %s: %w", ext.SoPath, err) } } - // Restore the ini files we modified in place at install (bringing back any - // xdebug we disabled). This runs last so it is authoritative over the loader - // removal above for a file that held both. for _, cb := range ext.ConfigBackups { if err := restoreBackup(cb.BackupPath, cb.OriginalPath); err != nil { return fmt.Errorf("restoring %s: %w", cb.OriginalPath, err) } opts.logf("Restored %s.", cb.OriginalPath) } - m.ClearExtension() - if err := finalizeManifest(layout, m); err != nil { - return fmt.Errorf("saving manifest: %w", err) - } - opts.logf("Uninstalled the php-debugger extension for php %s.", ext.Series) return nil } +// reconcileInvariant repairs a manifest left in the impossible state where both an +// interpreter and the extension are recorded. Older versions installed the +// interpreter without removing a previously installed extension; the interpreter +// supersedes it (its loader is already disabled), so the stale extension record is +// dropped and the manifest re-saved. The orphaned .so is inert (never loaded) and +// left in place rather than risk touching the user's files during an unrelated +// command. No-op for a consistent manifest. +func reconcileInvariant(layout platform.Layout, m *manifest.Manifest, opts Options) error { + if m.Extension == nil || len(m.InterpreterKeys()) == 0 { + return nil + } + opts.logf("Note: found a stale extension record alongside an installed interpreter; " + + "removing it (the interpreter includes the debugger and supersedes the extension).") + m.ClearExtension() + return m.Save(layout.ManifestPath()) +} + // removeExtensionLoader removes the debugger loader from an ini file: it drops // the zend_extension line pointing at soPath and our comment line. If the file // becomes effectively empty (it was a dedicated loader file) it is removed; diff --git a/internal/installer/uninstall_test.go b/internal/installer/uninstall_test.go index ac9523e..86ffa83 100644 --- a/internal/installer/uninstall_test.go +++ b/internal/installer/uninstall_test.go @@ -55,7 +55,7 @@ func TestUninstallInterpreterRestoresBackup(t *testing.T) { var out bytes.Buffer if err := Uninstall(context.Background(), Options{ Scope: platform.User, Out: &out, Env: &env, - }, false, false, "", false); err != nil { + }, "", false); err != nil { t.Fatalf("uninstall: %v\n%s", err, out.String()) } @@ -139,7 +139,7 @@ func TestUninstallInterpreterKeepsFilesWhenReassignFails(t *testing.T) { // Uninstalling the active (only) variant must attempt the backup restore, fail, // and leave everything intact. err := Uninstall(context.Background(), Options{Scope: platform.User, Out: &bytes.Buffer{}, Env: &env}, - false, false, "", false) + "", false) if err == nil { t.Fatal("expected uninstall to fail when backup restore fails") } @@ -208,7 +208,7 @@ func TestUninstallRestoresMultipleBackups(t *testing.T) { env := linuxUserEnv(home) if err := Uninstall(context.Background(), Options{Scope: platform.User, Out: &bytes.Buffer{}, Env: &env}, - true, false, "", false); err != nil { + "", false); err != nil { t.Fatalf("uninstall: %v", err) } @@ -279,7 +279,7 @@ func TestUninstallMultiBackupPartialFailureIsRetryable(t *testing.T) { uOpts := Options{Scope: platform.User, Out: &bytes.Buffer{}, Env: &env} // First attempt: 8.3 restores, 8.3#1 fails. - if err := Uninstall(context.Background(), uOpts, true, false, "", false); err == nil { + if err := Uninstall(context.Background(), uOpts, "", false); err == nil { t.Fatal("expected uninstall to fail on the sabotaged second restore") } @@ -310,7 +310,7 @@ func TestUninstallMultiBackupPartialFailureIsRetryable(t *testing.T) { if err := os.Remove(blocker); err != nil { t.Fatal(err) } - if err := Uninstall(context.Background(), uOpts, true, false, "", false); err != nil { + if err := Uninstall(context.Background(), uOpts, "", false); err != nil { t.Fatalf("retry uninstall: %v", err) } if data, err := os.ReadFile(orig1); err != nil || string(data) != "EXE-ORIG" { @@ -362,7 +362,7 @@ func TestUninstallActiveReassignsToOtherVariant(t *testing.T) { // Uninstall the active 8.4 -> 8.3 should become active. var out bytes.Buffer if err := Uninstall(context.Background(), Options{Scope: platform.User, Out: &out, Env: &env}, - false, false, "", false); err != nil { + "", false); err != nil { t.Fatalf("uninstall: %v", err) } @@ -398,7 +398,7 @@ func TestUninstallCleanHostRemovesSymlink(t *testing.T) { link := filepath.Join(home, ".local", "bin", "php") if err := Uninstall(context.Background(), Options{Scope: platform.User, Out: &bytes.Buffer{}, Env: &env}, - false, false, "", false); err != nil { + "", false); err != nil { t.Fatalf("uninstall: %v", err) } if _, err := os.Lstat(link); !os.IsNotExist(err) { @@ -438,7 +438,7 @@ func TestUninstallExtension(t *testing.T) { // Uninstall the extension. if err := Uninstall(context.Background(), Options{Scope: platform.User, Out: &bytes.Buffer{}, Env: &env}, - false, false, "", false); err != nil { + "", false); err != nil { t.Fatalf("uninstall extension: %v", err) } if _, err := os.Stat(soDst); !os.IsNotExist(err) { @@ -490,7 +490,7 @@ func TestUninstallExtensionRestoresXdebug(t *testing.T) { } if err := Uninstall(context.Background(), Options{Scope: platform.User, Out: &bytes.Buffer{}, Env: &env}, - false, false, "", false); err != nil { + "", false); err != nil { t.Fatalf("uninstall extension: %v", err) } // After uninstall, the ini is byte-for-byte the user's original (xdebug back). @@ -503,10 +503,44 @@ func TestUninstallExtensionRestoresXdebug(t *testing.T) { } } +// A manifest left in the impossible both-installed state (by an older buggy +// version) must not block uninstall with an ambiguity error: the stale extension +// record is reconciled away and the interpreter is uninstalled normally. +func TestUninstallReconcilesStaleExtension(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("fake php is a /bin/sh script") + } + home := t.TempDir() + root := filepath.Join(home, ".local", "share", "php-debugger") + binDir := filepath.Join(home, ".local", "bin") + versionDir := filepath.Join(root, "8.3") + writeExec(t, filepath.Join(versionDir, "bin", "php"), fakePHP("8.3.7", true, "", "")) + writeExec(t, filepath.Join(binDir, "php"), fakePHP("8.3.7", true, "", "")) + + m := manifest.New(root, binDir) + m.SetInterpreter("8.3", manifest.Interpreter{Series: "8.3", PHPVersion: "8.3.7", Dir: versionDir}) + m.SetActive("8.3") + m.SetExtension(manifest.Extension{Series: "8.3", ReleaseTag: "1.0.0"}) // stale, from the old bug + manifestPath := filepath.Join(root, "manifest.json") + if err := m.Save(manifestPath); err != nil { + t.Fatal(err) + } + + env := linuxUserEnv(home) + if err := Uninstall(context.Background(), Options{Scope: platform.User, Out: &bytes.Buffer{}, Env: &env}, + "", false); err != nil { + t.Fatalf("uninstall should reconcile and remove the interpreter, got: %v", err) + } + // Everything gone: the interpreter removed, the stale record cleaned, manifest wiped. + if _, err := os.Stat(manifestPath); !os.IsNotExist(err) { + t.Errorf("manifest should be gone after full uninstall, stat err=%v", err) + } +} + func TestUninstallNothing(t *testing.T) { env := linuxUserEnv(t.TempDir()) err := Uninstall(context.Background(), Options{Scope: platform.User, Env: &env}, - false, false, "", false) + "", false) if err == nil || !strings.Contains(err.Error(), "nothing installed") { t.Errorf("expected 'nothing installed', got %v", err) } diff --git a/internal/installer/update.go b/internal/installer/update.go index 534c019..e1f4d59 100644 --- a/internal/installer/update.go +++ b/internal/installer/update.go @@ -10,11 +10,11 @@ import ( "github.com/php-debugger/installer/internal/release" ) -// Update reinstalls the active interpreter and/or the extension against the -// latest release. With neither wantInterp nor wantExt set it updates whatever is -// installed, erroring if both are present (ambiguous). Each target is skipped if -// already on the latest release. -func Update(ctx context.Context, opts Options, wantInterp, wantExt bool) error { +// Update reinstalls whatever is installed — the active interpreter or the +// extension — against the latest release, skipping it if already up to date. The +// interpreter and the extension are never installed at once (see the invariant in +// InstallInterpreter), so there is nothing to disambiguate. +func Update(ctx context.Context, opts Options) error { env, err := opts.env() if err != nil { return err @@ -27,31 +27,16 @@ func Update(ctx context.Context, opts Options, wantInterp, wantExt bool) error { if err != nil { return err } + if err := reconcileInvariant(layout, m, opts); err != nil { + return err + } hasInterp := m.Active() != "" hasExt := m.Extension != nil - - if !wantInterp && !wantExt { - switch { - case hasInterp && hasExt: - return errors.New("both an interpreter and an extension are installed; " + - "specify --interpreter or --extension") - case hasInterp: - wantInterp = true - case hasExt: - wantExt = true - default: - return errors.New("nothing installed to update") - } - } - if wantInterp && !hasInterp { - return errors.New("no interpreter installed to update") - } - if wantExt && !hasExt { - return errors.New("no extension installed to update") + if !hasInterp && !hasExt { + return errors.New("nothing installed to update") } - // Fetch the latest release once and reuse it for the install(s). client := opts.Client if client == nil { client = release.NewClient() @@ -61,17 +46,10 @@ func Update(ctx context.Context, opts Options, wantInterp, wantExt bool) error { return err } - if wantInterp { - if err := updateInterpreter(ctx, opts, m, rel, client); err != nil { - return err - } - } - if wantExt { - if err := updateExtension(ctx, opts, m, rel, client); err != nil { - return err - } + if hasInterp { + return updateInterpreter(ctx, opts, m, rel, client) } - return nil + return updateExtension(ctx, opts, m, rel, client) } func updateInterpreter(ctx context.Context, opts Options, m *manifest.Manifest, rel *release.Release, client *release.Client) error { diff --git a/internal/installer/update_test.go b/internal/installer/update_test.go index 6a67e8d..1acfb58 100644 --- a/internal/installer/update_test.go +++ b/internal/installer/update_test.go @@ -84,7 +84,7 @@ func TestUpdateInterpreter(t *testing.T) { uo := opts uo.Out = &out uo.PHPVersion = "" // update figures out the target from the manifest - if err := Update(context.Background(), uo, false, false); err != nil { + if err := Update(context.Background(), uo); err != nil { t.Fatalf("Update: %v\n%s", err, out.String()) } @@ -127,7 +127,7 @@ func TestUpdateInterpreterActiveOnPATH(t *testing.T) { uo := opts uo.Out = &out uo.PHPVersion = "" - if err := Update(context.Background(), uo, false, false); err != nil { + if err := Update(context.Background(), uo); err != nil { t.Fatalf("Update: %v\n%s", err, out.String()) } @@ -153,7 +153,7 @@ func TestUpdateAlreadyLatest(t *testing.T) { var out bytes.Buffer uo := opts uo.Out = &out - if err := Update(context.Background(), uo, false, false); err != nil { + if err := Update(context.Background(), uo); err != nil { t.Fatalf("Update: %v", err) } if !strings.Contains(out.String(), "up to date") { @@ -176,7 +176,7 @@ func TestUpdateRollbackKeepsWorkingInstall(t *testing.T) { var out bytes.Buffer uo := opts uo.Out = &out - err := Update(context.Background(), uo, false, false) + err := Update(context.Background(), uo) if err == nil { t.Fatalf("expected update to fail on broken release\n%s", out.String()) } @@ -200,32 +200,45 @@ func TestUpdateRollbackKeepsWorkingInstall(t *testing.T) { func TestUpdateNothingInstalled(t *testing.T) { env := linuxUserEnv(t.TempDir()) - err := Update(context.Background(), Options{Scope: platform.User, Env: &env}, false, false) + err := Update(context.Background(), Options{Scope: platform.User, Env: &env}) if err == nil || !strings.Contains(err.Error(), "nothing installed") { t.Errorf("expected 'nothing installed' error, got: %v", err) } } -func TestUpdateAmbiguous(t *testing.T) { +// A manifest left in the impossible both-installed state (by an older buggy +// version) is reconciled: the stale extension record is dropped and the update +// proceeds on the interpreter, rather than erroring on a now-removed ambiguity. +func TestUpdateReconcilesStaleExtension(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("fake php is a /bin/sh script") } isolatePATH(t) home := t.TempDir() ms := newMutableServer(t, "1.0.0", fakePHP("8.3.7", true, "", "")) - _, manifestPath := installBaseInterpreter(t, home, ms) + opts, manifestPath := installBaseInterpreter(t, home, ms) - // Also record an extension so both are present. + // Simulate the old bug: an extension record left alongside the interpreter. m, _ := manifest.Load(manifestPath) m.SetExtension(manifest.Extension{Series: "8.3", ReleaseTag: "1.0.0"}) if err := m.Save(manifestPath); err != nil { t.Fatal(err) } - env := linuxUserEnv(home) - err := Update(context.Background(), Options{Scope: platform.User, Env: &env}, false, false) - if err == nil || !strings.Contains(err.Error(), "specify") { - t.Errorf("expected ambiguity error, got: %v", err) + ms.set("2.0.0", fakePHP("8.3.9", true, "", "")) + var out bytes.Buffer + uo := opts + uo.Out = &out + if err := Update(context.Background(), uo); err != nil { + t.Fatalf("update should reconcile and proceed, got: %v\n%s", err, out.String()) + } + + m, _ = manifest.Load(manifestPath) + if m.Extension != nil { + t.Error("stale extension record should have been reconciled away") + } + if it, _ := m.Interpreter("8.3"); it.ReleaseTag != "2.0.0" { + t.Errorf("interpreter should have updated to 2.0.0, got %+v", it) } } diff --git a/internal/manifest/manifest.go b/internal/manifest/manifest.go index 2d2446f..295b3f2 100644 --- a/internal/manifest/manifest.go +++ b/internal/manifest/manifest.go @@ -45,6 +45,12 @@ type Interpreter struct { // compiled-in config path (copied from a replaced interpreter), recorded so // uninstall can remove them. ConfigFiles []string `json:"configFiles,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 + // elsewhere. Their originals are backed up so uninstall restores them (bringing + // back a stripped xdebug) instead of deleting the user's configuration. + ConfigBackups []FileBackup `json:"configBackups,omitempty"` } // Backup records an interpreter that was replaced during an install, so it can