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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions internal/cli/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ func newInstallCmd() *cobra.Command {
ZTS: opts.ZTS,
AssumeYes: globalOpts.Yes,
Out: cmd.OutOrStdout(),
In: cmd.InOrStdin(),
})
},
}
Expand Down
27 changes: 27 additions & 0 deletions internal/ini/ini.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,33 @@ func StripXdebugLoaders(content string) (string, []string) {
return strings.Join(kept, "\n"), removed
}

// CommentExtensionLoaders comments out every active extension= / zend_extension=
// directive by prefixing it with "; ", returning the rewritten content and the
// list of lines that were commented. Already-commented loaders and non-loader
// lines are left unchanged.
//
// This is used when copying an existing php's config to a self-contained
// debugger interpreter, which cannot load foreign .so files (they are built for
// a specific PHP ABI). Commenting keeps them visible but inert. Note xdebug
// loaders are removed entirely by StripXdebugLoaders and never reach here.
func CommentExtensionLoaders(content string) (string, []string) {
lines := strings.Split(content, "\n")
var commented []string
for i, ln := range lines {
isComment, key, _, ok := parseDirective(ln)
if !ok || isComment || (key != "extension" && key != "zend_extension") {
continue
}
commented = append(commented, strings.TrimRight(ln, "\r"))
body, cr := ln, ""
if strings.HasSuffix(body, "\r") {
body, cr = body[:len(body)-1], "\r"
}
lines[i] = "; " + body + cr
}
return strings.Join(lines, "\n"), commented
}

// DisallowedModes returns the de-duplicated set of xdebug.mode tokens present in
// xdebug.mode directives (active or commented out) that are not in AllowedModes,
// preserving first-seen order. It is empty if xdebug.mode is absent or already
Expand Down
39 changes: 39 additions & 0 deletions internal/ini/ini_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,45 @@ func TestStripXdebugLoaders(t *testing.T) {
}
}

func TestCommentExtensionLoaders(t *testing.T) {
tests := []struct {
name string
in string
want string
wantCommented []string
}{
{
name: "comments active extension and zend_extension",
in: "extension=mysqli.so\nzend_extension=/opt/opcache.so\nmemory_limit=256M\n",
want: "; extension=mysqli.so\n; zend_extension=/opt/opcache.so\nmemory_limit=256M\n",
wantCommented: []string{"extension=mysqli.so", "zend_extension=/opt/opcache.so"},
},
{
name: "leaves already-commented and non-loaders alone",
in: ";extension=foo.so\ndisplay_errors=On\n",
want: ";extension=foo.so\ndisplay_errors=On\n",
wantCommented: nil,
},
{
name: "preserves CRLF",
in: "extension=mysqli.so\r\nmemory_limit=256M\r\n",
want: "; extension=mysqli.so\r\nmemory_limit=256M\r\n",
wantCommented: []string{"extension=mysqli.so"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, commented := CommentExtensionLoaders(tt.in)
if got != tt.want {
t.Errorf("content = %q, want %q", got, tt.want)
}
if !reflect.DeepEqual(commented, tt.wantCommented) {
t.Errorf("commented = %#v, want %#v", commented, tt.wantCommented)
}
})
}
}

func TestDisallowedModes(t *testing.T) {
tests := []struct {
name string
Expand Down
45 changes: 45 additions & 0 deletions internal/installer/backup.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package installer

import (
"fmt"
"os"
"path/filepath"
)

// backupExisting moves the existing interpreter at srcPath into backupDir under a
// unique name, preserving whatever it is (a real binary or a symlink). It falls
// back to copy+remove if the move crosses filesystems. Returns the backup path.
func backupExisting(srcPath, backupDir, key string, nowNanos int64) (string, error) {
if err := os.MkdirAll(backupDir, 0o755); err != nil {
return "", fmt.Errorf("creating backup dir: %w", err)
}
dst := filepath.Join(backupDir, fmt.Sprintf("php-%s-%d", key, nowNanos))

if err := os.Rename(srcPath, dst); err == nil {
return dst, nil
}
// Cross-device or other rename failure: copy the resolved binary, then remove
// the original.
if err := copyFile(srcPath, dst, 0o755); err != nil {
return "", fmt.Errorf("backing up %s: %w", srcPath, err)
}
if err := os.Remove(srcPath); err != nil {
os.Remove(dst)
return "", fmt.Errorf("removing original %s after backup: %w", srcPath, err)
}
return dst, nil
}

// restoreBackup moves a backup back to its original path.
func restoreBackup(backupPath, originalPath string) error {
if err := os.MkdirAll(filepath.Dir(originalPath), 0o755); err != nil {
return err
}
if err := os.Rename(backupPath, originalPath); err == nil {
return nil
}
if err := copyFile(backupPath, originalPath, 0o755); err != nil {
return err
}
return os.Remove(backupPath)
}
147 changes: 147 additions & 0 deletions internal/installer/iniconfig.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
package installer

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

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

// configPair is a source ini file and where its (sanitized) copy is written.
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
}

stripModes, err := decideStripModes(pairs, opts)
if err != nil {
return nil, err
}

var written []string
for _, pr := range pairs {
data, err := os.ReadFile(pr.src)
if err != nil {
return written, fmt.Errorf("reading ini %s: %w", pr.src, err)
}
content, removedLoaders := ini.StripXdebugLoaders(string(data))
content, commentedLoaders := ini.CommentExtensionLoaders(content)
if stripModes {
content, _, _ = ini.SanitizeXdebugMode(content)
}

if err := registerConfigUndo(pr.dst, rb); err != nil {
return written, err
}
if err := os.MkdirAll(filepath.Dir(pr.dst), 0o755); err != nil {
return written, fmt.Errorf("creating config dir: %w", err)
}
if err := os.WriteFile(pr.dst, []byte(content), 0o644); err != nil {
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)))
}
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 {
var pairs []configPair
if existing.Ini.LoadedFile != "" && target.Ini.ConfigPath != "" {
pairs = append(pairs, configPair{
src: existing.Ini.LoadedFile,
dst: filepath.Join(target.Ini.ConfigPath, "php.ini"),
})
}
if target.Ini.ScanDir != "" {
for _, f := range existing.Ini.AdditionalFiles {
pairs = append(pairs, configPair{
src: f,
dst: filepath.Join(target.Ini.ScanDir, filepath.Base(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.
func decideStripModes(pairs []configPair, opts Options) (bool, error) {
seen := map[string]bool{}
var disallowed []string
for _, pr := range pairs {
data, err := os.ReadFile(pr.src)
if err != nil {
return false, fmt.Errorf("reading ini %s: %w", pr.src, err)
}
for _, m := range ini.DisallowedModes(string(data)) {
if !seen[m] {
seen[m] = true
disallowed = append(disallowed, m)
}
}
}
if len(disallowed) == 0 {
return false, nil
}
sort.Strings(disallowed)
return opts.confirm(fmt.Sprintf(
"xdebug.mode lists disallowed mode(s): %s. Remove them (keeping only off/debug)?",
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) })
} else if os.IsNotExist(err) {
rb.add(func() error { return removeIfExists(dst) })
} else {
return fmt.Errorf("inspecting %s: %w", dst, err)
}
return nil
}

func removeIfExists(path string) error {
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}

// loaderNote summarizes what happened to extension loaders in a copied ini file.
func loaderNote(removed, commented int) string {
var parts []string
if removed > 0 {
parts = append(parts, fmt.Sprintf("removed %d xdebug loader(s)", removed))
}
if commented > 0 {
parts = append(parts, fmt.Sprintf("commented %d extension loader(s)", commented))
}
if len(parts) == 0 {
return ""
}
return " (" + strings.Join(parts, ", ") + ")"
}
Loading
Loading