Skip to content

Commit d084586

Browse files
Existing interpreter handler (#9)
1 parent ccdf4cf commit d084586

10 files changed

Lines changed: 650 additions & 67 deletions

File tree

‎internal/cli/install.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ func newInstallCmd() *cobra.Command {
4040
ZTS: opts.ZTS,
4141
AssumeYes: globalOpts.Yes,
4242
Out: cmd.OutOrStdout(),
43+
In: cmd.InOrStdin(),
4344
})
4445
},
4546
}

‎internal/ini/ini.go‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,33 @@ func StripXdebugLoaders(content string) (string, []string) {
3333
return strings.Join(kept, "\n"), removed
3434
}
3535

36+
// CommentExtensionLoaders comments out every active extension= / zend_extension=
37+
// directive by prefixing it with "; ", returning the rewritten content and the
38+
// list of lines that were commented. Already-commented loaders and non-loader
39+
// lines are left unchanged.
40+
//
41+
// This is used when copying an existing php's config to a self-contained
42+
// debugger interpreter, which cannot load foreign .so files (they are built for
43+
// a specific PHP ABI). Commenting keeps them visible but inert. Note xdebug
44+
// loaders are removed entirely by StripXdebugLoaders and never reach here.
45+
func CommentExtensionLoaders(content string) (string, []string) {
46+
lines := strings.Split(content, "\n")
47+
var commented []string
48+
for i, ln := range lines {
49+
isComment, key, _, ok := parseDirective(ln)
50+
if !ok || isComment || (key != "extension" && key != "zend_extension") {
51+
continue
52+
}
53+
commented = append(commented, strings.TrimRight(ln, "\r"))
54+
body, cr := ln, ""
55+
if strings.HasSuffix(body, "\r") {
56+
body, cr = body[:len(body)-1], "\r"
57+
}
58+
lines[i] = "; " + body + cr
59+
}
60+
return strings.Join(lines, "\n"), commented
61+
}
62+
3663
// DisallowedModes returns the de-duplicated set of xdebug.mode tokens present in
3764
// xdebug.mode directives (active or commented out) that are not in AllowedModes,
3865
// preserving first-seen order. It is empty if xdebug.mode is absent or already

‎internal/ini/ini_test.go‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,45 @@ func TestStripXdebugLoaders(t *testing.T) {
8080
}
8181
}
8282

83+
func TestCommentExtensionLoaders(t *testing.T) {
84+
tests := []struct {
85+
name string
86+
in string
87+
want string
88+
wantCommented []string
89+
}{
90+
{
91+
name: "comments active extension and zend_extension",
92+
in: "extension=mysqli.so\nzend_extension=/opt/opcache.so\nmemory_limit=256M\n",
93+
want: "; extension=mysqli.so\n; zend_extension=/opt/opcache.so\nmemory_limit=256M\n",
94+
wantCommented: []string{"extension=mysqli.so", "zend_extension=/opt/opcache.so"},
95+
},
96+
{
97+
name: "leaves already-commented and non-loaders alone",
98+
in: ";extension=foo.so\ndisplay_errors=On\n",
99+
want: ";extension=foo.so\ndisplay_errors=On\n",
100+
wantCommented: nil,
101+
},
102+
{
103+
name: "preserves CRLF",
104+
in: "extension=mysqli.so\r\nmemory_limit=256M\r\n",
105+
want: "; extension=mysqli.so\r\nmemory_limit=256M\r\n",
106+
wantCommented: []string{"extension=mysqli.so"},
107+
},
108+
}
109+
for _, tt := range tests {
110+
t.Run(tt.name, func(t *testing.T) {
111+
got, commented := CommentExtensionLoaders(tt.in)
112+
if got != tt.want {
113+
t.Errorf("content = %q, want %q", got, tt.want)
114+
}
115+
if !reflect.DeepEqual(commented, tt.wantCommented) {
116+
t.Errorf("commented = %#v, want %#v", commented, tt.wantCommented)
117+
}
118+
})
119+
}
120+
}
121+
83122
func TestDisallowedModes(t *testing.T) {
84123
tests := []struct {
85124
name string

‎internal/installer/backup.go‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package installer
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"path/filepath"
7+
)
8+
9+
// backupExisting moves the existing interpreter at srcPath into backupDir under a
10+
// unique name, preserving whatever it is (a real binary or a symlink). It falls
11+
// back to copy+remove if the move crosses filesystems. Returns the backup path.
12+
func backupExisting(srcPath, backupDir, key string, nowNanos int64) (string, error) {
13+
if err := os.MkdirAll(backupDir, 0o755); err != nil {
14+
return "", fmt.Errorf("creating backup dir: %w", err)
15+
}
16+
dst := filepath.Join(backupDir, fmt.Sprintf("php-%s-%d", key, nowNanos))
17+
18+
if err := os.Rename(srcPath, dst); err == nil {
19+
return dst, nil
20+
}
21+
// Cross-device or other rename failure: copy the resolved binary, then remove
22+
// the original.
23+
if err := copyFile(srcPath, dst, 0o755); err != nil {
24+
return "", fmt.Errorf("backing up %s: %w", srcPath, err)
25+
}
26+
if err := os.Remove(srcPath); err != nil {
27+
os.Remove(dst)
28+
return "", fmt.Errorf("removing original %s after backup: %w", srcPath, err)
29+
}
30+
return dst, nil
31+
}
32+
33+
// restoreBackup moves a backup back to its original path.
34+
func restoreBackup(backupPath, originalPath string) error {
35+
if err := os.MkdirAll(filepath.Dir(originalPath), 0o755); err != nil {
36+
return err
37+
}
38+
if err := os.Rename(backupPath, originalPath); err == nil {
39+
return nil
40+
}
41+
if err := copyFile(backupPath, originalPath, 0o755); err != nil {
42+
return err
43+
}
44+
return os.Remove(backupPath)
45+
}

‎internal/installer/iniconfig.go‎

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
package installer
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"path/filepath"
7+
"sort"
8+
"strings"
9+
10+
"github.com/php-debugger/installer/internal/ini"
11+
"github.com/php-debugger/installer/internal/php"
12+
)
13+
14+
// configPair is a source ini file and where its (sanitized) copy is written.
15+
type configPair struct{ src, dst string }
16+
17+
// copyConfig copies the existing interpreter's ini files into the new
18+
// interpreter's compiled-in config path (so the new php loads the same
19+
// configuration), sanitizing each on the way: xdebug loader lines are stripped,
20+
// and disallowed xdebug.mode tokens are removed after confirmation.
21+
//
22+
// It registers undo steps on rb (restoring overwritten files / removing created
23+
// ones) and returns the list of destination files written.
24+
func copyConfig(existing, target *php.Info, rb *rollback, opts Options) ([]string, error) {
25+
pairs := configPairs(existing, target)
26+
if len(pairs) == 0 {
27+
if target.Ini.ConfigPath == "" && target.Ini.ScanDir == "" {
28+
opts.logf("Note: the interpreter reports no config path; skipping ini copy.")
29+
}
30+
return nil, nil
31+
}
32+
33+
stripModes, err := decideStripModes(pairs, opts)
34+
if err != nil {
35+
return nil, err
36+
}
37+
38+
var written []string
39+
for _, pr := range pairs {
40+
data, err := os.ReadFile(pr.src)
41+
if err != nil {
42+
return written, fmt.Errorf("reading ini %s: %w", pr.src, err)
43+
}
44+
content, removedLoaders := ini.StripXdebugLoaders(string(data))
45+
content, commentedLoaders := ini.CommentExtensionLoaders(content)
46+
if stripModes {
47+
content, _, _ = ini.SanitizeXdebugMode(content)
48+
}
49+
50+
if err := registerConfigUndo(pr.dst, rb); err != nil {
51+
return written, err
52+
}
53+
if err := os.MkdirAll(filepath.Dir(pr.dst), 0o755); err != nil {
54+
return written, fmt.Errorf("creating config dir: %w", err)
55+
}
56+
if err := os.WriteFile(pr.dst, []byte(content), 0o644); err != nil {
57+
return written, fmt.Errorf("writing ini %s: %w", pr.dst, err)
58+
}
59+
written = append(written, pr.dst)
60+
opts.logf(" wrote %s%s", pr.dst, loaderNote(len(removedLoaders), len(commentedLoaders)))
61+
}
62+
return written, nil
63+
}
64+
65+
// configPairs builds the (source, destination) list: the existing main php.ini
66+
// goes to the new interpreter's ConfigPath, and each additional .ini goes to its
67+
// ScanDir (by base name).
68+
func configPairs(existing, target *php.Info) []configPair {
69+
var pairs []configPair
70+
if existing.Ini.LoadedFile != "" && target.Ini.ConfigPath != "" {
71+
pairs = append(pairs, configPair{
72+
src: existing.Ini.LoadedFile,
73+
dst: filepath.Join(target.Ini.ConfigPath, "php.ini"),
74+
})
75+
}
76+
if target.Ini.ScanDir != "" {
77+
for _, f := range existing.Ini.AdditionalFiles {
78+
pairs = append(pairs, configPair{
79+
src: f,
80+
dst: filepath.Join(target.Ini.ScanDir, filepath.Base(f)),
81+
})
82+
}
83+
}
84+
return pairs
85+
}
86+
87+
// decideStripModes scans the source ini files for disallowed xdebug.mode tokens
88+
// and, if any are found, asks the user whether to remove them (auto-yes under
89+
// --yes). Returns true if the caller should sanitize xdebug.mode.
90+
func decideStripModes(pairs []configPair, opts Options) (bool, error) {
91+
seen := map[string]bool{}
92+
var disallowed []string
93+
for _, pr := range pairs {
94+
data, err := os.ReadFile(pr.src)
95+
if err != nil {
96+
return false, fmt.Errorf("reading ini %s: %w", pr.src, err)
97+
}
98+
for _, m := range ini.DisallowedModes(string(data)) {
99+
if !seen[m] {
100+
seen[m] = true
101+
disallowed = append(disallowed, m)
102+
}
103+
}
104+
}
105+
if len(disallowed) == 0 {
106+
return false, nil
107+
}
108+
sort.Strings(disallowed)
109+
return opts.confirm(fmt.Sprintf(
110+
"xdebug.mode lists disallowed mode(s): %s. Remove them (keeping only off/debug)?",
111+
strings.Join(disallowed, ", "))), nil
112+
}
113+
114+
// registerConfigUndo records how to undo writing dst: restore its prior contents
115+
// if it existed, otherwise remove it on rollback.
116+
func registerConfigUndo(dst string, rb *rollback) error {
117+
if prev, err := os.ReadFile(dst); err == nil {
118+
rb.add(func() error { return os.WriteFile(dst, prev, 0o644) })
119+
} else if os.IsNotExist(err) {
120+
rb.add(func() error { return removeIfExists(dst) })
121+
} else {
122+
return fmt.Errorf("inspecting %s: %w", dst, err)
123+
}
124+
return nil
125+
}
126+
127+
func removeIfExists(path string) error {
128+
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
129+
return err
130+
}
131+
return nil
132+
}
133+
134+
// loaderNote summarizes what happened to extension loaders in a copied ini file.
135+
func loaderNote(removed, commented int) string {
136+
var parts []string
137+
if removed > 0 {
138+
parts = append(parts, fmt.Sprintf("removed %d xdebug loader(s)", removed))
139+
}
140+
if commented > 0 {
141+
parts = append(parts, fmt.Sprintf("commented %d extension loader(s)", commented))
142+
}
143+
if len(parts) == 0 {
144+
return ""
145+
}
146+
return " (" + strings.Join(parts, ", ") + ")"
147+
}

0 commit comments

Comments
 (0)