From 12a44d4f64dc3b17ec40e4e714b834dc8147af25 Mon Sep 17 00:00:00 2001 From: Carlos Granados Date: Sat, 25 Jul 2026 07:45:51 +0200 Subject: [PATCH] PHP package --- internal/cli/resolve.go | 30 +++++++ internal/php/parse.go | 113 +++++++++++++++++++++++++ internal/php/parse_test.go | 147 +++++++++++++++++++++++++++++++++ internal/php/php.go | 140 +++++++++++++++++++++++++++++++ internal/platform/arch.go | 37 +++++++++ internal/platform/arch_test.go | 66 +++++++++++++++ internal/platform/platform.go | 11 ++- 7 files changed, 542 insertions(+), 2 deletions(-) create mode 100644 internal/php/parse.go create mode 100644 internal/php/parse_test.go create mode 100644 internal/php/php.go create mode 100644 internal/platform/arch.go create mode 100644 internal/platform/arch_test.go diff --git a/internal/cli/resolve.go b/internal/cli/resolve.go index 2163de6..27b487e 100644 --- a/internal/cli/resolve.go +++ b/internal/cli/resolve.go @@ -3,8 +3,10 @@ package cli import ( "context" "fmt" + "io" "time" + "github.com/php-debugger/installer/internal/php" "github.com/php-debugger/installer/internal/platform" "github.com/php-debugger/installer/internal/release" "github.com/spf13/cobra" @@ -90,5 +92,33 @@ func runResolve(cmd *cobra.Command, opts *resolveOptions) error { if asset.Size > 0 { fmt.Fprintf(out, "size: %.1f MiB\n", float64(asset.Size)/(1024*1024)) } + + printExistingPHP(ctx, out) return nil } + +// printExistingPHP prints facts about the php interpreter currently on PATH, for +// diagnostics. It never fails the command. +func printExistingPHP(ctx context.Context, out io.Writer) { + path, err := php.Detect() + if err != nil { + fmt.Fprintf(out, "existing: none on PATH\n") + return + } + info, err := php.Query(ctx, path) + if err != nil { + fmt.Fprintf(out, "existing: %s (could not query: %v)\n", path, err) + return + } + loaded := info.Ini.LoadedFile + if loaded == "" { + loaded = "(none)" + } + fmt.Fprintf(out, "existing: %s\n", info.Path) + fmt.Fprintf(out, " version: %s (series %s, zts=%t)\n", info.Version, info.Series, info.ZTS) + fmt.Fprintf(out, " loaded ini: %s\n", loaded) + fmt.Fprintf(out, " extension_dir: %s\n", info.ExtensionDir) + if len(info.Ini.AdditionalFiles) > 0 { + fmt.Fprintf(out, " extra ini: %d file(s)\n", len(info.Ini.AdditionalFiles)) + } +} diff --git a/internal/php/parse.go b/internal/php/parse.go new file mode 100644 index 0000000..7c4e64e --- /dev/null +++ b/internal/php/parse.go @@ -0,0 +1,113 @@ +package php + +import "strings" + +// IniPaths captures the ini file locations reported by `php --ini`. +type IniPaths struct { + // LoadedFile is the main php.ini actually loaded ("" if none). + LoadedFile string + // ScanDir is the directory scanned for additional .ini files ("" if none). + ScanDir string + // AdditionalFiles are the extra .ini files parsed, in order. + AdditionalFiles []string +} + +// parseKeyVals parses simple "key=value" lines (as emitted by our -r info +// script) into a map. Lines without '=' are ignored. +func parseKeyVals(out string) map[string]string { + m := map[string]string{} + for _, ln := range strings.Split(out, "\n") { + eq := strings.IndexByte(ln, '=') + if eq < 0 { + continue + } + key := strings.TrimSpace(ln[:eq]) + if key == "" { + continue + } + m[key] = strings.TrimSpace(ln[eq+1:]) + } + return m +} + +// parseIniOutput parses the output of `php --ini`. +func parseIniOutput(out string) IniPaths { + var p IniPaths + lines := strings.Split(out, "\n") + for i, ln := range lines { + switch { + case strings.HasPrefix(ln, "Loaded Configuration File:"): + p.LoadedFile = cleanIniValue(afterColon(ln)) + case strings.HasPrefix(ln, "Scan for additional .ini files in:"): + p.ScanDir = cleanIniValue(afterColon(ln)) + case strings.HasPrefix(ln, "Additional .ini files parsed:"): + // The value can wrap across subsequent indented lines; this is the + // last labelled section, so absorb everything that follows. + rest := afterColon(ln) + for _, cont := range lines[i+1:] { + rest += " " + cont + } + p.AdditionalFiles = parseFileList(rest) + } + } + return p +} + +// parseModules parses the output of `php -m` into a list of module names, +// skipping section headers ("[PHP Modules]", "[Zend Modules]") and blanks. +func parseModules(out string) []string { + var mods []string + for _, ln := range strings.Split(out, "\n") { + ln = strings.TrimSpace(ln) + if ln == "" || strings.HasPrefix(ln, "[") { + continue + } + mods = append(mods, ln) + } + return mods +} + +// parseVersionLine extracts the version and thread-safety from the first line of +// `php -v`, e.g. "PHP 8.3.10 (cli) (built: ...) (NTS)". It is a fallback used to +// verify a freshly downloaded binary before trusting it to run -r scripts. +func parseVersionLine(out string) (version string, zts bool, ok bool) { + line := out + if idx := strings.IndexByte(out, '\n'); idx >= 0 { + line = out[:idx] + } + fields := strings.Fields(line) + if len(fields) < 2 || fields[0] != "PHP" { + return "", false, false + } + return fields[1], strings.Contains(line, "ZTS"), true +} + +func afterColon(s string) string { + if i := strings.IndexByte(s, ':'); i >= 0 { + return s[i+1:] + } + return s +} + +// cleanIniValue trims a value and normalizes PHP's "(none)" placeholder to "". +func cleanIniValue(s string) string { + s = strings.TrimSpace(s) + if s == "(none)" { + return "" + } + return s +} + +// parseFileList splits a comma/whitespace separated list of file paths, dropping +// empties and "(none)". +func parseFileList(s string) []string { + var out []string + for _, part := range strings.Split(s, ",") { + part = strings.TrimSpace(part) + if part == "" || part == "(none)" { + continue + } + out = append(out, part) + } + return out +} diff --git a/internal/php/parse_test.go b/internal/php/parse_test.go new file mode 100644 index 0000000..ba62f11 --- /dev/null +++ b/internal/php/parse_test.go @@ -0,0 +1,147 @@ +package php + +import ( + "reflect" + "testing" +) + +func TestParseKeyVals(t *testing.T) { + out := "version=8.3.10\nseries=8.3\nzts=0\nextension_dir=/usr/lib/php/20230831\n" + kv := parseKeyVals(out) + want := map[string]string{ + "version": "8.3.10", + "series": "8.3", + "zts": "0", + "extension_dir": "/usr/lib/php/20230831", + } + if !reflect.DeepEqual(kv, want) { + t.Errorf("parseKeyVals = %v, want %v", kv, want) + } +} + +func TestParseKeyValsEmptyExtensionDir(t *testing.T) { + kv := parseKeyVals("version=8.2.1\nseries=8.2\nzts=1\nextension_dir=\n") + if kv["extension_dir"] != "" { + t.Errorf("empty extension_dir = %q, want empty", kv["extension_dir"]) + } + if kv["zts"] != "1" { + t.Errorf("zts = %q, want 1", kv["zts"]) + } +} + +func TestParseIniOutputTypical(t *testing.T) { + out := `Configuration File (php.ini) Path: /etc/php/8.3/cli +Loaded Configuration File: /etc/php/8.3/cli/php.ini +Scan for additional .ini files in: /etc/php/8.3/cli/conf.d +Additional .ini files parsed: /etc/php/8.3/cli/conf.d/10-opcache.ini, +/etc/php/8.3/cli/conf.d/20-xdebug.ini, +/etc/php/8.3/cli/conf.d/30-mysqli.ini +` + got := parseIniOutput(out) + want := IniPaths{ + LoadedFile: "/etc/php/8.3/cli/php.ini", + ScanDir: "/etc/php/8.3/cli/conf.d", + AdditionalFiles: []string{ + "/etc/php/8.3/cli/conf.d/10-opcache.ini", + "/etc/php/8.3/cli/conf.d/20-xdebug.ini", + "/etc/php/8.3/cli/conf.d/30-mysqli.ini", + }, + } + if !reflect.DeepEqual(got, want) { + t.Errorf("parseIniOutput =\n%+v\nwant\n%+v", got, want) + } +} + +func TestParseIniOutputNone(t *testing.T) { + out := `Configuration File (php.ini) Path: /usr/local/etc/php +Loaded Configuration File: (none) +Scan for additional .ini files in: (none) +Additional .ini files parsed: (none) +` + got := parseIniOutput(out) + if got.LoadedFile != "" { + t.Errorf("LoadedFile = %q, want empty", got.LoadedFile) + } + if got.ScanDir != "" { + t.Errorf("ScanDir = %q, want empty", got.ScanDir) + } + if len(got.AdditionalFiles) != 0 { + t.Errorf("AdditionalFiles = %v, want empty", got.AdditionalFiles) + } +} + +func TestParseIniOutputSingleAdditional(t *testing.T) { + out := `Loaded Configuration File: /etc/php.ini +Scan for additional .ini files in: /etc/php.d +Additional .ini files parsed: /etc/php.d/20-xdebug.ini +` + got := parseIniOutput(out) + if len(got.AdditionalFiles) != 1 || got.AdditionalFiles[0] != "/etc/php.d/20-xdebug.ini" { + t.Errorf("AdditionalFiles = %v", got.AdditionalFiles) + } +} + +func TestParseModules(t *testing.T) { + out := `[PHP Modules] +Core +date +json +mysqli +xdebug + +[Zend Modules] +Xdebug +Zend OPcache +` + got := parseModules(out) + want := []string{"Core", "date", "json", "mysqli", "xdebug", "Xdebug", "Zend OPcache"} + if !reflect.DeepEqual(got, want) { + t.Errorf("parseModules = %v, want %v", got, want) + } +} + +func TestParseVersionLine(t *testing.T) { + tests := []struct { + name string + in string + wantVersion string + wantZTS bool + wantOK bool + }{ + { + name: "nts cli", + in: "PHP 8.3.10 (cli) (built: Jul 1 2024 10:00:00) (NTS)\nCopyright (c) The PHP Group", + wantVersion: "8.3.10", + wantZTS: false, + wantOK: true, + }, + { + name: "zts", + in: "PHP 8.2.20 (cli) (built: Jun 1 2024) (ZTS)", + wantVersion: "8.2.20", + wantZTS: true, + wantOK: true, + }, + { + name: "garbage", + in: "not php output", + wantOK: false, + }, + { + name: "empty", + in: "", + wantOK: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + v, zts, ok := parseVersionLine(tt.in) + if ok != tt.wantOK { + t.Fatalf("ok = %v, want %v", ok, tt.wantOK) + } + if ok && (v != tt.wantVersion || zts != tt.wantZTS) { + t.Errorf("got (%q, %v), want (%q, %v)", v, zts, tt.wantVersion, tt.wantZTS) + } + }) + } +} diff --git a/internal/php/php.go b/internal/php/php.go new file mode 100644 index 0000000..725adee --- /dev/null +++ b/internal/php/php.go @@ -0,0 +1,140 @@ +// Package php inspects PHP interpreters: it finds an existing php on PATH, runs a +// binary to smoke-test that it executes on this host, and queries its facts +// (version, thread safety, ini file locations, extension_dir, loaded modules). +// +// Output parsing lives in parse.go and is pure/unit-tested; this file holds the +// thin exec wrappers. +package php + +import ( + "bytes" + "context" + "errors" + "fmt" + "os/exec" + "strings" +) + +// ErrNotFound is returned by Detect when no php interpreter is on PATH. +var ErrNotFound = errors.New("no php interpreter found on PATH") + +// DebuggerModule is the module name the php-debugger extension registers, as +// reported by `php -m`. Install verification checks for it via HasModule. +const DebuggerModule = "php-debugger" + +// Info describes a PHP interpreter. +type Info struct { + Path string // path to the php binary + Version string // full version, e.g. "8.3.10" + Series string // major.minor, e.g. "8.3" + ZTS bool // thread-safe build + ExtensionDir string // ini_get("extension_dir") + Ini IniPaths +} + +// infoScript prints the interpreter facts as key=value lines. Using -r keeps the +// output locale-independent (unlike parsing -v / -i prose). +const infoScript = `printf("version=%s\nseries=%s\nzts=%d\nextension_dir=%s\n",` + + ` PHP_VERSION, PHP_MAJOR_VERSION.".".PHP_MINOR_VERSION, PHP_ZTS, ini_get("extension_dir"));` + +// Detect returns the path to the php interpreter on PATH, or ErrNotFound. +func Detect() (string, error) { + path, err := exec.LookPath("php") + if err != nil { + return "", ErrNotFound + } + return path, nil +} + +// SmokeError reports that a binary failed to run `php -v`. It carries the +// combined output so the caller can show it to the user. +type SmokeError struct { + Binary string + Output string + Err error +} + +func (e *SmokeError) Error() string { + out := strings.TrimSpace(e.Output) + if out == "" { + return fmt.Sprintf("%s failed to run: %v", e.Binary, e.Err) + } + return fmt.Sprintf("%s failed to run: %v\n%s", e.Binary, e.Err, out) +} + +func (e *SmokeError) Unwrap() error { return e.Err } + +// SmokeTest runs `binary -v` to confirm the interpreter executes on this host. +// On failure it returns a *SmokeError with the captured output. +func SmokeTest(ctx context.Context, binary string) error { + cmd := exec.CommandContext(ctx, binary, "-v") + out, err := cmd.CombinedOutput() + if err != nil { + return &SmokeError{Binary: binary, Output: string(out), Err: err} + } + return nil +} + +// Query runs the interpreter to gather its Info (version, thread safety, +// extension_dir and ini paths). +func Query(ctx context.Context, binary string) (*Info, error) { + info := &Info{Path: binary} + + out, err := run(ctx, binary, "-r", infoScript) + if err != nil { + return nil, fmt.Errorf("querying php info: %w", err) + } + kv := parseKeyVals(out) + info.Version = kv["version"] + info.Series = kv["series"] + info.ZTS = kv["zts"] == "1" + info.ExtensionDir = kv["extension_dir"] + + iniOut, err := run(ctx, binary, "--ini") + if err != nil { + return nil, fmt.Errorf("querying php ini paths: %w", err) + } + info.Ini = parseIniOutput(iniOut) + + return info, nil +} + +// Modules lists the modules reported by `binary -m`. +func Modules(ctx context.Context, binary string) ([]string, error) { + out, err := run(ctx, binary, "-m") + if err != nil { + return nil, fmt.Errorf("listing php modules: %w", err) + } + return parseModules(out), nil +} + +// HasModule reports whether `binary -m` lists a module named name +// (case-insensitively). +func HasModule(ctx context.Context, binary, name string) (bool, error) { + mods, err := Modules(ctx, binary) + if err != nil { + return false, err + } + for _, m := range mods { + if strings.EqualFold(m, name) { + return true, nil + } + } + return false, nil +} + +// run executes a php command, returning stdout on success. On failure the error +// includes stderr. +func run(ctx context.Context, binary string, args ...string) (string, error) { + cmd := exec.CommandContext(ctx, binary, args...) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + if msg := strings.TrimSpace(stderr.String()); msg != "" { + return "", fmt.Errorf("%w: %s", err, msg) + } + return "", err + } + return stdout.String(), nil +} diff --git a/internal/platform/arch.go b/internal/platform/arch.go new file mode 100644 index 0000000..abecdac --- /dev/null +++ b/internal/platform/arch.go @@ -0,0 +1,37 @@ +package platform + +import ( + "os/exec" + "strconv" + "strings" +) + +// detectNativeArch upgrades a detected architecture to the host's *native* +// architecture when they differ due to emulation. Concretely: an x86_64 build of +// this tool running on Apple Silicon (under Rosetta 2) reports GOARCH=amd64, but +// the machine is arm64 and should get arm64 PHP. sysctlInt is injected so the +// logic is unit-testable without a real macOS host. +func detectNativeArch(osID OS, arch Arch, sysctlInt func(name string) (int, bool)) Arch { + if osID == MacOS && arch == X8664 { + // hw.optional.arm64 == 1 means the hardware is Apple Silicon; combined + // with an amd64 build this means we're running translated. + if v, ok := sysctlInt("hw.optional.arm64"); ok && v == 1 { + return Arm64 + } + } + return arch +} + +// sysctlInt reads an integer sysctl value by name, returning ok=false if the key +// is absent or non-integer (e.g. on Intel Macs hw.optional.arm64 does not exist). +func sysctlInt(name string) (int, bool) { + out, err := exec.Command("sysctl", "-n", name).Output() + if err != nil { + return 0, false + } + v, err := strconv.Atoi(strings.TrimSpace(string(out))) + if err != nil { + return 0, false + } + return v, true +} diff --git a/internal/platform/arch_test.go b/internal/platform/arch_test.go new file mode 100644 index 0000000..dae4623 --- /dev/null +++ b/internal/platform/arch_test.go @@ -0,0 +1,66 @@ +package platform + +import "testing" + +func TestDetectNativeArch(t *testing.T) { + // fakeSysctl returns a fixed hw.optional.arm64 result. + fakeSysctl := func(val int, ok bool) func(string) (int, bool) { + return func(name string) (int, bool) { + if name == "hw.optional.arm64" { + return val, ok + } + return 0, false + } + } + + tests := []struct { + name string + os OS + arch Arch + sysctl func(string) (int, bool) + want Arch + }{ + { + name: "amd64 build on apple silicon upgrades to arm64", + os: MacOS, + arch: X8664, + sysctl: fakeSysctl(1, true), + want: Arm64, + }, + { + name: "amd64 build on intel mac stays amd64", + os: MacOS, + arch: X8664, + sysctl: fakeSysctl(0, false), // sysctl key absent on Intel + want: X8664, + }, + { + name: "arm64 build on apple silicon unchanged (sysctl not consulted)", + os: MacOS, + arch: Arm64, + sysctl: func(string) (int, bool) { t.Fatal("sysctl should not be called"); return 0, false }, + want: Arm64, + }, + { + name: "linux amd64 unchanged", + os: Linux, + arch: X8664, + sysctl: func(string) (int, bool) { t.Fatal("sysctl should not be called"); return 0, false }, + want: X8664, + }, + { + name: "windows amd64 unchanged", + os: Windows, + arch: X8664, + sysctl: func(string) (int, bool) { t.Fatal("sysctl should not be called"); return 0, false }, + want: X8664, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := detectNativeArch(tt.os, tt.arch, tt.sysctl); got != tt.want { + t.Errorf("detectNativeArch(%s, %s) = %s, want %s", tt.os, tt.arch, got, tt.want) + } + }) + } +} diff --git a/internal/platform/platform.go b/internal/platform/platform.go index e13cbdc..615b9c6 100644 --- a/internal/platform/platform.go +++ b/internal/platform/platform.go @@ -80,7 +80,14 @@ func DetectFor(goos, goarch string) (Platform, error) { return Platform{OS: osID, Arch: arch}, nil } -// Detect resolves the Platform of the host the installer is running on. +// Detect resolves the Platform of the host the installer is running on. It +// corrects the architecture to the machine's native one when the tool is running +// emulated (an amd64 build on Apple Silicon), so we install native binaries. func Detect() (Platform, error) { - return DetectFor(runtime.GOOS, runtime.GOARCH) + p, err := DetectFor(runtime.GOOS, runtime.GOARCH) + if err != nil { + return Platform{}, err + } + p.Arch = detectNativeArch(p.OS, p.Arch, sysctlInt) + return p, nil }