diff --git a/internal/cli/install.go b/internal/cli/install.go index 4d0ee89..25e7a5a 100644 --- a/internal/cli/install.go +++ b/internal/cli/install.go @@ -30,18 +30,18 @@ func newInstallCmd() *cobra.Command { "into the currently active PHP.", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { - if opts.ExtensionOnly { - // Extension-only install is implemented in a later step. - return errNotImplemented("extension install") - } - return installer.InstallInterpreter(cmd.Context(), installer.Options{ + o := installer.Options{ Scope: platform.ScopeFromUserFlag(globalOpts.User), PHPVersion: opts.PHPVersion, ZTS: opts.ZTS, AssumeYes: globalOpts.Yes, Out: cmd.OutOrStdout(), In: cmd.InOrStdin(), - }) + } + if opts.ExtensionOnly { + return installer.InstallExtension(cmd.Context(), o) + } + return installer.InstallInterpreter(cmd.Context(), o) }, } diff --git a/internal/installer/extension.go b/internal/installer/extension.go new file mode 100644 index 0000000..eeff40c --- /dev/null +++ b/internal/installer/extension.go @@ -0,0 +1,204 @@ +package installer + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + + "github.com/php-debugger/installer/internal/manifest" + "github.com/php-debugger/installer/internal/php" + "github.com/php-debugger/installer/internal/platform" + "github.com/php-debugger/installer/internal/release" +) + +// ErrNoInterpreter is returned by InstallExtension when no php is found on PATH. +var ErrNoInterpreter = errors.New( + "no PHP interpreter found on PATH.\n" + + "The extension needs an existing PHP to install into. Install the debugger\n" + + "interpreter instead (php-debugger install), or install PHP and retry.") + +// InstallExtension installs just the debugger extension into the current PHP: +// it downloads the extension matching that php's version/threading, copies it +// into the php's extension_dir, disables any real xdebug in the existing ini +// files, enables the extension, and verifies it loads — reverting everything on +// failure. +func InstallExtension(ctx context.Context, opts Options) error { + env, err := opts.env() + if err != nil { + return err + } + p := platform.Platform{OS: env.OS, Arch: env.Arch} + + layout, err := platform.Resolve(env, opts.Scope) + if err != nil { + return err + } + + // Require an existing interpreter. + path, err := php.Detect() + if err != nil { + return ErrNoInterpreter + } + existing, err := php.Query(ctx, path) + if err != nil { + return fmt.Errorf("querying php at %s: %w", path, err) + } + if existing.ExtensionDir == "" { + return fmt.Errorf("php at %s reports no extension_dir; cannot install the extension", path) + } + + // Nothing to do if the debugger is already present (e.g. our own interpreter). + if has, _ := php.HasModule(ctx, path, php.DebuggerModule); has { + opts.logf("%s already has the %s module; nothing to do.", path, php.DebuggerModule) + return nil + } + + client := opts.Client + if client == nil { + client = release.NewClient() + } + rel, err := client.LatestRelease(ctx) + if err != nil { + return err + } + asset, err := release.SelectAsset(rel.Assets, release.Selector{ + Kind: release.Extension, + Series: existing.Series, + ZTS: existing.ZTS, + OS: p.OS, + Arch: p.Arch, + }) + if err != nil { + return err + } + + opts.logf("Installing php-debugger extension for php %s (%s) %s, release %s", + existing.Series, threading(existing.ZTS), p, rel.TagName) + + tmpDir, err := os.MkdirTemp("", "php-debugger-ext-") + if err != nil { + return err + } + defer os.RemoveAll(tmpDir) + + opts.logf("Downloading %s ...", asset.Name) + dlPath, err := client.Download(ctx, asset, tmpDir) + if err != nil { + return err + } + + rb := &rollback{} + + // --- copy the extension into extension_dir --- + soDst := filepath.Join(existing.ExtensionDir, asset.Name) + if err := registerFileRestore(soDst, rb, 0o755); err != nil { + return err + } + if err := installFile(dlPath, soDst); err != nil { + return fmt.Errorf("installing extension into %s: %w", existing.ExtensionDir, err) + } + opts.logf(" copied %s", soDst) + + // --- disable any real xdebug in the existing ini files --- + if err := stripXdebugFromExisting(existing, rb, opts); err != nil { + rb.run() + return fmt.Errorf("updating existing ini files; reverted: %w", err) + } + + // --- enable the extension --- + iniPath, err := enableExtension(existing, soDst, rb) + if err != nil { + rb.run() + return fmt.Errorf("enabling extension; reverted: %w", err) + } + opts.logf(" enabled via %s", iniPath) + + // --- verify it loads --- + opts.logf("Confirming the extension loads ...") + has, err := php.HasModule(ctx, path, php.DebuggerModule) + if err != nil { + rb.run() + return fmt.Errorf("checking for the debugger module; reverted: %w", err) + } + if !has { + rb.run() + return fmt.Errorf("the extension did not load in %s (its build may not match this php); reverted", path) + } + + // --- record in the manifest --- + m, err := manifest.Load(layout.ManifestPath()) + if err != nil { + rb.run() + return err + } + m.InstallRoot = layout.Root + m.SetExtension(manifest.Extension{ + Series: existing.Series, + PHPVersion: existing.Version, + ZTS: existing.ZTS, + ReleaseTag: rel.TagName, + SoPath: soDst, + IniPath: iniPath, + InstalledAt: opts.clock(), + }) + if err := m.Save(layout.ManifestPath()); err != nil { + rb.run() + return fmt.Errorf("saving manifest; reverted: %w", err) + } + + opts.logf("Installed the php-debugger extension into %s.", path) + return nil +} + +// stripXdebugFromExisting disables a real xdebug in the existing php's ini files +// (in place) so it does not conflict with the debugger's simulated one. It reuses +// the shared ini rules (see rewriteIniFiles) without commenting other loaders — +// the existing php can load its own extensions. +func stripXdebugFromExisting(existing *php.Info, rb *rollback, opts Options) error { + var files []string + if existing.Ini.LoadedFile != "" { + files = append(files, existing.Ini.LoadedFile) + } + files = append(files, existing.Ini.AdditionalFiles...) + return sanitizeInPlace(files, rb, opts) +} + +// enableExtension writes a loader that enables the debugger extension: a +// dedicated ini in the scan dir if there is one, otherwise appended to the main +// php.ini. Returns the ini file path. Registers undo on rb. +func enableExtension(existing *php.Info, soPath string, rb *rollback) (string, error) { + line := "; Enables the php-debugger extension (added by php-debugger)\nzend_extension=" + soPath + "\n" + + if existing.Ini.ScanDir != "" { + iniPath := filepath.Join(existing.Ini.ScanDir, "99-php-debugger.ini") + if err := registerFileRestore(iniPath, rb, 0o644); err != nil { + return "", err + } + if err := os.MkdirAll(existing.Ini.ScanDir, 0o755); err != nil { + return "", fmt.Errorf("creating scan dir: %w", err) + } + if err := os.WriteFile(iniPath, []byte(line), 0o644); err != nil { + return "", err + } + return iniPath, nil + } + + if existing.Ini.LoadedFile != "" { + iniPath := existing.Ini.LoadedFile + prev, err := os.ReadFile(iniPath) + if err != nil { + return "", fmt.Errorf("reading %s: %w", iniPath, err) + } + if err := registerFileRestore(iniPath, rb, 0o644); err != nil { + return "", err + } + if err := os.WriteFile(iniPath, append(prev, []byte("\n"+line)...), 0o644); err != nil { + return "", err + } + return iniPath, nil + } + + return "", errors.New("no ini file available to enable the extension (php reports no scan dir or loaded php.ini)") +} diff --git a/internal/installer/extension_test.go b/internal/installer/extension_test.go new file mode 100644 index 0000000..ee37e74 --- /dev/null +++ b/internal/installer/extension_test.go @@ -0,0 +1,196 @@ +package installer + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/php-debugger/installer/internal/manifest" + "github.com/php-debugger/installer/internal/platform" + "github.com/php-debugger/installer/internal/release" +) + +// fakeExistingPHPForExt builds a fake php whose `-m` lists php_debugger only when +// the loader ini exists AND simulateLoad is true — mimicking dynamic loading via +// the enabled extension. It reports the given extension_dir and ini locations. +func fakeExistingPHPForExt(version, extDir, loadedFile, scanDir string, simulateLoad bool) string { + series := version + if i := strings.LastIndex(version, "."); i >= 0 { + series = version[:i] + } + sim := "0" + if simulateLoad { + sim = "1" + } + loader := filepath.Join(scanDir, "99-php-debugger.ini") + return fmt.Sprintf(`#!/bin/sh +case "$1" in + -v) echo "PHP %s (cli) (built: Jan 1 2026) (NTS)" ;; + -m) + printf '[PHP Modules]\nCore\ndate\nxdebug\n' + if [ "%s" = "1" ] && [ -f "%s" ]; then printf 'php_debugger\n'; fi + ;; + --ini) + echo "Loaded Configuration File: \"%s\"" + echo "Scan for additional .ini files in: \"%s\"" ;; + -r) printf 'version=%s\nseries=%s\nzts=0\nextension_dir=%s\n' ;; + *) : ;; +esac +exit 0 +`, version, sim, loader, loadedFile, scanDir, version, series, extDir) +} + +func TestInstallExtensionNoPHP(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("fake php is a /bin/sh script") + } + isolatePATH(t) + env := linuxUserEnv(t.TempDir()) + err := InstallExtension(context.Background(), Options{Scope: platform.User, Env: &env}) + if !errors.Is(err, ErrNoInterpreter) { + t.Fatalf("expected ErrNoInterpreter, got %v", err) + } +} + +func TestInstallExtensionSuccess(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") + if err := os.WriteFile(loadedFile, []byte("zend_extension=xdebug.so\nmemory_limit=100M\n"), 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, "unused") + client := release.NewClient() + client.BaseURL = srv.URL + env := linuxUserEnv(home) + + var out bytes.Buffer + err := InstallExtension(context.Background(), Options{ + Scope: platform.User, AssumeYes: true, Out: &out, Client: client, Env: &env, + }) + if err != nil { + t.Fatalf("InstallExtension: %v\n%s", err, out.String()) + } + + // .so copied into extension_dir + soDst := filepath.Join(extDir, "php-debugger-php8.3-nts-linux-x86_64.so") + if b, err := os.ReadFile(soDst); err != nil || string(b) != fakeSO { + t.Errorf("extension .so not copied correctly: %v", err) + } + // loader ini created and points at the .so + loader := filepath.Join(scanDir, "99-php-debugger.ini") + lb, err := os.ReadFile(loader) + if err != nil || !strings.Contains(string(lb), "zend_extension="+soDst) { + t.Errorf("loader ini wrong: %q (err %v)", string(lb), err) + } + // xdebug loader stripped from the existing php.ini + mainIni, _ := os.ReadFile(loadedFile) + if strings.Contains(string(mainIni), "xdebug.so") { + t.Errorf("xdebug loader not stripped:\n%s", mainIni) + } + if !strings.Contains(string(mainIni), "memory_limit=100M") { + t.Errorf("unrelated settings should be preserved:\n%s", mainIni) + } + // manifest records the extension + m, err := manifest.Load(filepath.Join(home, ".local", "share", "php-debugger", "manifest.json")) + if err != nil { + t.Fatal(err) + } + if m.Extension == nil || m.Extension.SoPath != soDst || m.Extension.IniPath != loader { + t.Errorf("extension not recorded correctly: %+v", m.Extension) + } +} + +func TestInstallExtensionRevertsOnLoadFailure(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") + if err := os.WriteFile(loadedFile, []byte("memory_limit=100M\n"), 0o644); err != nil { + t.Fatal(err) + } + + binDir := filepath.Join(t.TempDir(), "bin") + // simulateLoad=false: even after enabling, -m never lists php_debugger. + writeExec(t, filepath.Join(binDir, "php"), + fakeExistingPHPForExt("8.3.7", extDir, loadedFile, scanDir, false)) + t.Setenv("PATH", binDir) + + srv := newFakeReleaseServer(t, "unused") + client := release.NewClient() + client.BaseURL = srv.URL + env := linuxUserEnv(home) + + var out bytes.Buffer + err := InstallExtension(context.Background(), Options{ + Scope: platform.User, AssumeYes: true, Out: &out, Client: client, Env: &env, + }) + if err == nil { + t.Fatalf("expected load-failure error\n%s", out.String()) + } + if !strings.Contains(err.Error(), "reverted") { + t.Errorf("error should mention revert, got: %v", err) + } + + // .so and loader ini must be gone; manifest must not exist. + if _, err := os.Stat(filepath.Join(extDir, "php-debugger-php8.3-nts-linux-x86_64.so")); !os.IsNotExist(err) { + t.Error("extension .so should have been reverted") + } + if _, err := os.Stat(filepath.Join(scanDir, "99-php-debugger.ini")); !os.IsNotExist(err) { + t.Error("loader ini should have been reverted") + } + if _, err := os.Stat(filepath.Join(home, ".local", "share", "php-debugger", "manifest.json")); !os.IsNotExist(err) { + t.Error("manifest should not exist after revert") + } +} + +func TestInstallExtensionAlreadyPresent(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("fake php is a /bin/sh script") + } + home := t.TempDir() + extDir := t.TempDir() + scanDir := filepath.Join(t.TempDir(), "conf.d") + if err := os.MkdirAll(scanDir, 0o755); err != nil { + t.Fatal(err) + } + // Pre-create the loader so the fake reports php_debugger from the start. + if err := os.WriteFile(filepath.Join(scanDir, "99-php-debugger.ini"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + binDir := filepath.Join(t.TempDir(), "bin") + writeExec(t, filepath.Join(binDir, "php"), + fakeExistingPHPForExt("8.3.7", extDir, "", scanDir, true)) + t.Setenv("PATH", binDir) + + env := linuxUserEnv(home) + var out bytes.Buffer + err := InstallExtension(context.Background(), Options{Scope: platform.User, Out: &out, Env: &env}) + if err != nil { + t.Fatalf("already-present should be a no-op, got: %v", err) + } + if !strings.Contains(out.String(), "nothing to do") { + t.Errorf("expected 'nothing to do' message, got: %s", out.String()) + } +} diff --git a/internal/installer/iniconfig.go b/internal/installer/iniconfig.go index 40797ab..f65eeed 100644 --- a/internal/installer/iniconfig.go +++ b/internal/installer/iniconfig.go @@ -12,24 +12,28 @@ import ( ) // configPair is a source ini file and where its (sanitized) copy is written. +// For in-place edits src == dst. type configPair struct{ src, dst string } -// 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: xdebug loader lines are stripped, -// and disallowed xdebug.mode tokens are removed after confirmation. -// -// It registers undo steps on rb (restoring overwritten files / removing created -// ones) and returns the list of destination files written. -func copyConfig(existing, target *php.Info, rb *rollback, opts Options) ([]string, error) { - pairs := configPairs(existing, target) - if len(pairs) == 0 { - if target.Ini.ConfigPath == "" && target.Ini.ScanDir == "" { - opts.logf("Note: the interpreter reports no config path; skipping ini copy.") - } - return nil, nil - } +// iniRewriteOptions captures the two ways the interpreter and extension flows +// differ when applying the ini rules. +type iniRewriteOptions struct { + // commentOtherLoaders comments out non-xdebug extension= / zend_extension= + // loaders. Used for the interpreter (a self-contained build cannot load + // foreign .so files); not for the extension (the existing php loads its own). + commentOtherLoaders bool + // skipUnchanged avoids writing (and registering undo for) files the rules + // leave untouched. Used for in-place edits of an existing php's config. + skipUnchanged bool +} +// rewriteIniFiles applies the shared ini rules to a set of (src -> dst) pairs: +// strip xdebug loaders, optionally comment other extension loaders, and — after +// a single confirmation covering all files — sanitize disallowed xdebug.mode +// tokens. It registers undo steps on rb and returns the destination files +// written. This is the one place the xdebug/ini handling lives; the interpreter +// and extension flows differ only via iniRewriteOptions. +func rewriteIniFiles(pairs []configPair, cfg iniRewriteOptions, rb *rollback, opts Options) ([]string, error) { stripModes, err := decideStripModes(pairs, opts) if err != nil { return nil, err @@ -42,12 +46,18 @@ func copyConfig(existing, target *php.Info, rb *rollback, opts Options) ([]strin return written, fmt.Errorf("reading ini %s: %w", pr.src, err) } content, removedLoaders := ini.StripXdebugLoaders(string(data)) - content, commentedLoaders := ini.CommentExtensionLoaders(content) + var commentedLoaders []string + if cfg.commentOtherLoaders { + content, commentedLoaders = ini.CommentExtensionLoaders(content) + } if stripModes { content, _, _ = ini.SanitizeXdebugMode(content) } + if cfg.skipUnchanged && content == string(data) { + continue + } - if err := registerConfigUndo(pr.dst, rb); err != nil { + if err := registerFileRestore(pr.dst, rb, 0o644); err != nil { return written, err } if err := os.MkdirAll(filepath.Dir(pr.dst), 0o755); err != nil { @@ -57,15 +67,36 @@ func copyConfig(existing, target *php.Info, rb *rollback, opts Options) ([]strin return written, fmt.Errorf("writing ini %s: %w", pr.dst, err) } written = append(written, pr.dst) - opts.logf(" wrote %s%s", pr.dst, loaderNote(len(removedLoaders), len(commentedLoaders))) + opts.logf(" %s%s", pr.dst, loaderNote(len(removedLoaders), len(commentedLoaders))) } return written, nil } -// configPairs builds the (source, destination) list: the existing main php.ini -// goes to the new interpreter's ConfigPath, and each additional .ini goes to its -// ScanDir (by base name). -func configPairs(existing, target *php.Info) []configPair { +// 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. +func copyConfig(existing, target *php.Info, rb *rollback, opts Options) ([]string, error) { + pairs := interpreterConfigPairs(existing, target) + if len(pairs) == 0 { + if target.Ini.ConfigPath == "" && target.Ini.ScanDir == "" { + opts.logf("Note: the interpreter reports no config path; skipping ini copy.") + } + return nil, nil + } + return rewriteIniFiles(pairs, iniRewriteOptions{commentOtherLoaders: true}, rb, opts) +} + +// sanitizeInPlace applies the xdebug ini rules to the given existing ini files +// in place (used by the extension flow to disable a real xdebug). Only files the +// rules change are touched. +func sanitizeInPlace(files []string, rb *rollback, opts Options) error { + _, err := rewriteIniFiles(inPlacePairs(files), iniRewriteOptions{skipUnchanged: true}, rb, opts) + return 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 { var pairs []configPair if existing.Ini.LoadedFile != "" && target.Ini.ConfigPath != "" { pairs = append(pairs, configPair{ @@ -84,6 +115,15 @@ func configPairs(existing, target *php.Info) []configPair { return pairs } +// 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)) + for _, f := range files { + pairs = append(pairs, configPair{src: f, dst: f}) + } + return pairs +} + // decideStripModes scans the source ini files for disallowed xdebug.mode tokens // and, if any are found, asks the user whether to remove them (auto-yes under // --yes). Returns true if the caller should sanitize xdebug.mode. @@ -111,15 +151,15 @@ func decideStripModes(pairs []configPair, opts Options) (bool, error) { strings.Join(disallowed, ", "))), nil } -// registerConfigUndo records how to undo writing dst: restore its prior contents -// if it existed, otherwise remove it on rollback. -func registerConfigUndo(dst string, rb *rollback) error { - if prev, err := os.ReadFile(dst); err == nil { - rb.add(func() error { return os.WriteFile(dst, prev, 0o644) }) +// registerFileRestore records how to undo writing path: restore its prior +// contents (with perm) if it existed, otherwise remove it on rollback. +func registerFileRestore(path string, rb *rollback, perm os.FileMode) error { + if prev, err := os.ReadFile(path); err == nil { + rb.add(func() error { return os.WriteFile(path, prev, perm) }) } else if os.IsNotExist(err) { - rb.add(func() error { return removeIfExists(dst) }) + rb.add(func() error { return removeIfExists(path) }) } else { - return fmt.Errorf("inspecting %s: %w", dst, err) + return fmt.Errorf("inspecting %s: %w", path, err) } return nil } @@ -131,7 +171,8 @@ func removeIfExists(path string) error { return nil } -// loaderNote summarizes what happened to extension loaders in a copied ini file. +// loaderNote summarizes what happened to extension loaders in a rewritten ini +// file, e.g. " (removed 1 xdebug loader(s), commented 2 extension loader(s))". func loaderNote(removed, commented int) string { var parts []string if removed > 0 { diff --git a/internal/installer/install_test.go b/internal/installer/install_test.go index 687df36..46809bd 100644 --- a/internal/installer/install_test.go +++ b/internal/installer/install_test.go @@ -53,8 +53,10 @@ exit 0 `, version, modules, cfgDir, scanDir, version, series) } -// newFakeReleaseServer serves a latest-release payload pointing at a single -// interpreter asset whose bytes are the given fake php script. +const fakeSO = "FAKE-DEBUGGER-SO-BYTES" + +// newFakeReleaseServer serves a latest-release payload with both an interpreter +// asset (bytes = phpScript) and an extension asset (bytes = fakeSO). func newFakeReleaseServer(t *testing.T, phpScript string) *httptest.Server { t.Helper() var srv *httptest.Server @@ -62,10 +64,13 @@ func newFakeReleaseServer(t *testing.T, phpScript string) *httptest.Server { switch { case strings.HasSuffix(r.URL.Path, "/releases/latest"): fmt.Fprintf(w, `{"tag_name":"9.9.9","assets":[ - {"name":"php-php8.3-nts-linux-x86_64","browser_download_url":%q,"size":%d} - ]}`, srv.URL+"/dl/php", len(phpScript)) + {"name":"php-php8.3-nts-linux-x86_64","browser_download_url":%q,"size":%d}, + {"name":"php-debugger-php8.3-nts-linux-x86_64.so","browser_download_url":%q,"size":%d} + ]}`, srv.URL+"/dl/php", len(phpScript), srv.URL+"/dl/ext", len(fakeSO)) case r.URL.Path == "/dl/php": w.Write([]byte(phpScript)) + case r.URL.Path == "/dl/ext": + w.Write([]byte(fakeSO)) default: http.NotFound(w, r) }