Skip to content

Commit 353567b

Browse files
Improvements after review (#17)
1 parent 175bc75 commit 353567b

19 files changed

Lines changed: 1886 additions & 100 deletions

‎README.md‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,10 @@ Windows (PowerShell):
2121
powershell -c "irm https://github.com/php-debugger/installer/releases/latest/download/install.ps1 | iex"
2222
```
2323

24-
The script detects your OS/arch, downloads the right archive, and installs the
25-
binary (into the current directory by default; set `INSTALL_DIR` to change it, or
26-
`VERSION` to pin a release). Because it fetches with `curl`/`wget` rather than a
27-
browser, the binary is **not quarantined**, so macOS Gatekeeper doesn't block it.
24+
The script detects your OS/arch, downloads the latest archive, and installs the
25+
binary (into the current directory by default; set `INSTALL_DIR` to change it).
26+
Because it fetches with `curl`/`wget` rather than a browser, the binary is **not
27+
quarantined**, so macOS Gatekeeper doesn't block it.
2828

2929
### Download a prebuilt binary
3030

‎internal/ini/ini.go‎

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,21 @@ var AllowedModes = []string{"off", "debug"}
1919
// rewritten content and the list of removed lines (trimmed of any trailing CR)
2020
// for reporting.
2121
func StripXdebugLoaders(content string) (string, []string) {
22+
return stripZendLoaders(content, referencesXdebug)
23+
}
24+
25+
// stripZendLoaders removes every `zend_extension=` directive whose value matches,
26+
// whether the line is active or commented out, returning the rewritten content
27+
// and the removed lines (trimmed of any trailing CR) for reporting. Only
28+
// zend_extension is considered, because both xdebug and the php-debugger
29+
// extension load solely via it.
30+
func stripZendLoaders(content string, matches func(value string) bool) (string, []string) {
2231
lines := strings.Split(content, "\n")
2332
kept := make([]string, 0, len(lines))
2433
var removed []string
2534
for _, ln := range lines {
2635
_, key, value, ok := parseDirective(ln)
27-
if ok && key == "zend_extension" && referencesXdebug(value) {
36+
if ok && key == "zend_extension" && matches(value) {
2837
removed = append(removed, strings.TrimRight(ln, "\r"))
2938
continue
3039
}
@@ -60,6 +69,21 @@ func CommentExtensionLoaders(content string) (string, []string) {
6069
return strings.Join(lines, "\n"), commented
6170
}
6271

72+
// StripPhpDebuggerLoaders removes every `zend_extension=` directive that loads
73+
// the php-debugger extension (its value references the php-debugger .so), whether
74+
// active or commented, returning the rewritten content and the removed lines.
75+
// Like xdebug, the extension only loads via zend_extension, so `extension=` lines
76+
// are left alone.
77+
//
78+
// It is applied when copying an existing php's config onto the self-contained
79+
// debugger interpreter, which already has the debugger compiled in: loading the
80+
// standalone extension on top would register the module twice. Unlike
81+
// CommentExtensionLoaders this runs regardless of ABI match, because the built-in
82+
// debugger supersedes the extension in every case.
83+
func StripPhpDebuggerLoaders(content string) (string, []string) {
84+
return stripZendLoaders(content, referencesDebugger)
85+
}
86+
6387
// DisallowedModes returns the de-duplicated set of xdebug.mode tokens present in
6488
// xdebug.mode directives (active or commented out) that are not in AllowedModes,
6589
// preserving first-seen order. It is empty if xdebug.mode is absent or already
@@ -180,6 +204,14 @@ func referencesXdebug(value string) bool {
180204
return strings.Contains(strings.ToLower(unquote(value)), "xdebug")
181205
}
182206

207+
// referencesDebugger reports whether a loader value points at the php-debugger
208+
// extension. Its .so is named php-debugger-*, while the module reports as
209+
// php_debugger; accept either spelling to be safe.
210+
func referencesDebugger(value string) bool {
211+
v := strings.ToLower(unquote(value))
212+
return strings.Contains(v, "php-debugger") || strings.Contains(v, "php_debugger")
213+
}
214+
183215
func parseModeList(value string) []string {
184216
var out []string
185217
for _, p := range strings.Split(unquote(value), ",") {

‎internal/ini/ini_test.go‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,51 @@ func TestCommentExtensionLoaders(t *testing.T) {
119119
}
120120
}
121121

122+
func TestStripPhpDebuggerLoaders(t *testing.T) {
123+
tests := []struct {
124+
name string
125+
in string
126+
want string
127+
wantRemoved []string
128+
}{
129+
{
130+
name: "removes the php-debugger zend_extension loader",
131+
in: "zend_extension=/usr/lib/php/ext/php-debugger-php8.3-nts-linux-x86_64.so\nmemory_limit=256M\n",
132+
want: "memory_limit=256M\n",
133+
wantRemoved: []string{"zend_extension=/usr/lib/php/ext/php-debugger-php8.3-nts-linux-x86_64.so"},
134+
},
135+
{
136+
name: "accepts the underscore spelling and removes commented loaders too",
137+
in: "; zend_extension=php_debugger.so\n",
138+
want: "",
139+
wantRemoved: []string{"; zend_extension=php_debugger.so"},
140+
},
141+
{
142+
name: "ignores extension= (loads only via zend_extension)",
143+
in: "extension=php-debugger.so\n",
144+
want: "extension=php-debugger.so\n",
145+
wantRemoved: nil,
146+
},
147+
{
148+
name: "leaves unrelated loaders alone",
149+
in: "zend_extension=xdebug.so\nextension=mysqli.so\n",
150+
want: "zend_extension=xdebug.so\nextension=mysqli.so\n",
151+
wantRemoved: nil,
152+
},
153+
}
154+
for _, tt := range tests {
155+
t.Run(tt.name, func(t *testing.T) {
156+
got, removed := StripPhpDebuggerLoaders(tt.in)
157+
if got != tt.want {
158+
t.Errorf("content = %q, want %q", got, tt.want)
159+
}
160+
if !reflect.DeepEqual(removed, tt.wantRemoved) {
161+
t.Errorf("removed = %#v, want %#v", removed, tt.wantRemoved)
162+
}
163+
})
164+
}
165+
}
166+
122167
func TestDisallowedModes(t *testing.T) {
123168
tests := []struct {
124169
name string

‎internal/installer/backup.go‎

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,18 +9,32 @@ import (
99
// backupExisting moves the existing interpreter at srcPath into backupDir under a
1010
// unique name, preserving whatever it is (a real binary or a symlink). It falls
1111
// back to copy+remove if the move crosses filesystems. Returns the backup path.
12-
func backupExisting(srcPath, backupDir, key string, nowNanos int64) (string, error) {
12+
//
13+
// The destination is reserved with CreateTemp so its name is guaranteed unique:
14+
// one install can back up several files under the same key (e.g. a Windows php.exe
15+
// and php.cmd sharing an active slot), and a deterministic or timestamp-based name
16+
// could collide and let one backup silently overwrite another. The original's
17+
// basename is kept in the name for traceability.
18+
func backupExisting(srcPath, backupDir, key string) (string, error) {
1319
if err := os.MkdirAll(backupDir, 0o755); err != nil {
1420
return "", fmt.Errorf("creating backup dir: %w", err)
1521
}
16-
dst := filepath.Join(backupDir, fmt.Sprintf("php-%s-%d", key, nowNanos))
22+
f, err := os.CreateTemp(backupDir, "php-"+key+"-*-"+filepath.Base(srcPath))
23+
if err != nil {
24+
return "", fmt.Errorf("reserving backup path for %s: %w", srcPath, err)
25+
}
26+
dst := f.Name()
27+
f.Close()
1728

29+
// Move the original into the reserved path (os.Rename atomically replaces the
30+
// empty placeholder on both Unix and Windows).
1831
if err := os.Rename(srcPath, dst); err == nil {
1932
return dst, nil
2033
}
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 {
34+
// Cross-device or other rename failure: copy into the reserved path, then remove
35+
// the original. copyNode preserves a symlink as a symlink (matching rename).
36+
if err := copyNode(srcPath, dst, 0o755); err != nil {
37+
os.Remove(dst)
2438
return "", fmt.Errorf("backing up %s: %w", srcPath, err)
2539
}
2640
if err := os.Remove(srcPath); err != nil {
@@ -30,16 +44,39 @@ func backupExisting(srcPath, backupDir, key string, nowNanos int64) (string, err
3044
return dst, nil
3145
}
3246

33-
// restoreBackup moves a backup back to its original path.
47+
// restoreBackup moves a backup back to its original path, preserving a symlink as
48+
// a symlink whether it moves (rename) or falls back to copy across filesystems.
3449
func restoreBackup(backupPath, originalPath string) error {
3550
if err := os.MkdirAll(filepath.Dir(originalPath), 0o755); err != nil {
3651
return err
3752
}
3853
if err := os.Rename(backupPath, originalPath); err == nil {
3954
return nil
4055
}
41-
if err := copyFile(backupPath, originalPath, 0o755); err != nil {
56+
if err := copyNode(backupPath, originalPath, 0o755); err != nil {
4257
return err
4358
}
4459
return os.Remove(backupPath)
4560
}
61+
62+
// copyNode copies src to dst for the cross-filesystem move fallbacks. A symlink is
63+
// recreated as a symlink (its target is not dereferenced); a regular file has its
64+
// contents copied with perm.
65+
func copyNode(src, dst string, perm os.FileMode) error {
66+
fi, err := os.Lstat(src)
67+
if err != nil {
68+
return err
69+
}
70+
if fi.Mode()&os.ModeSymlink != 0 {
71+
target, err := os.Readlink(src)
72+
if err != nil {
73+
return err
74+
}
75+
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
76+
return fmt.Errorf("creating %s: %w", filepath.Dir(dst), err)
77+
}
78+
_ = os.Remove(dst) // os.Symlink fails if dst already exists
79+
return os.Symlink(target, dst)
80+
}
81+
return copyFile(src, dst, perm)
82+
}

‎internal/installer/backup_test.go‎

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
package installer
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"runtime"
7+
"testing"
8+
)
9+
10+
// copyNode backs the cross-filesystem move fallback. A symlink must survive as a
11+
// symlink (target not dereferenced), matching the rename path it stands in for.
12+
func TestCopyNodePreservesSymlink(t *testing.T) {
13+
if runtime.GOOS == "windows" {
14+
t.Skip("symlinks need privileges on Windows")
15+
}
16+
dir := t.TempDir()
17+
realTarget := filepath.Join(dir, "real-php")
18+
if err := os.WriteFile(realTarget, []byte("BINARY"), 0o755); err != nil {
19+
t.Fatal(err)
20+
}
21+
src := filepath.Join(dir, "php") // symlink -> real-php
22+
if err := os.Symlink(realTarget, src); err != nil {
23+
t.Fatal(err)
24+
}
25+
26+
dst := filepath.Join(dir, "backup", "php")
27+
if err := copyNode(src, dst, 0o755); err != nil {
28+
t.Fatalf("copyNode: %v", err)
29+
}
30+
31+
fi, err := os.Lstat(dst)
32+
if err != nil {
33+
t.Fatalf("lstat dst: %v", err)
34+
}
35+
if fi.Mode()&os.ModeSymlink == 0 {
36+
t.Fatal("dst should be a symlink, not a dereferenced regular file")
37+
}
38+
got, err := os.Readlink(dst)
39+
if err != nil || got != realTarget {
40+
t.Errorf("symlink target = %q (err %v), want %q", got, err, realTarget)
41+
}
42+
}
43+
44+
func TestCopyNodeCopiesRegularFile(t *testing.T) {
45+
dir := t.TempDir()
46+
src := filepath.Join(dir, "bin")
47+
if err := os.WriteFile(src, []byte("CONTENTS"), 0o755); err != nil {
48+
t.Fatal(err)
49+
}
50+
dst := filepath.Join(dir, "sub", "bin")
51+
if err := copyNode(src, dst, 0o755); err != nil {
52+
t.Fatalf("copyNode: %v", err)
53+
}
54+
if isLink, _ := isSymlinkNode(dst); isLink {
55+
t.Error("regular file should not become a symlink")
56+
}
57+
if b, err := os.ReadFile(dst); err != nil || string(b) != "CONTENTS" {
58+
t.Errorf("dst contents = %q (err %v), want CONTENTS", b, err)
59+
}
60+
}
61+
62+
// Two files backed up under the same key in one install (e.g. a Windows php.exe
63+
// and php.cmd) must get distinct backup paths — even sharing a basename — so one
64+
// never overwrites the other.
65+
func TestBackupExistingUniquePaths(t *testing.T) {
66+
dir := t.TempDir()
67+
backups := filepath.Join(dir, "backups")
68+
69+
// Two distinct sources sharing a basename ("php"), backed up under one key.
70+
srcA := filepath.Join(dir, "a", "php")
71+
srcB := filepath.Join(dir, "b", "php")
72+
if err := os.MkdirAll(filepath.Dir(srcA), 0o755); err != nil {
73+
t.Fatal(err)
74+
}
75+
if err := os.MkdirAll(filepath.Dir(srcB), 0o755); err != nil {
76+
t.Fatal(err)
77+
}
78+
if err := os.WriteFile(srcA, []byte("AAA"), 0o755); err != nil {
79+
t.Fatal(err)
80+
}
81+
if err := os.WriteFile(srcB, []byte("BBB"), 0o755); err != nil {
82+
t.Fatal(err)
83+
}
84+
85+
pa, err := backupExisting(srcA, backups, "8.3")
86+
if err != nil {
87+
t.Fatal(err)
88+
}
89+
pb, err := backupExisting(srcB, backups, "8.3")
90+
if err != nil {
91+
t.Fatal(err)
92+
}
93+
94+
if pa == pb {
95+
t.Fatalf("backup paths collided: %q", pa)
96+
}
97+
if data, _ := os.ReadFile(pa); string(data) != "AAA" {
98+
t.Errorf("backup A content = %q, want AAA", data)
99+
}
100+
if data, _ := os.ReadFile(pb); string(data) != "BBB" {
101+
t.Errorf("backup B content = %q, want BBB", data)
102+
}
103+
// Both sources were moved into their backups.
104+
if _, err := os.Stat(srcA); !os.IsNotExist(err) {
105+
t.Error("srcA should have been moved")
106+
}
107+
if _, err := os.Stat(srcB); !os.IsNotExist(err) {
108+
t.Error("srcB should have been moved")
109+
}
110+
}
111+
112+
func isSymlinkNode(path string) (bool, error) {
113+
fi, err := os.Lstat(path)
114+
if err != nil {
115+
return false, err
116+
}
117+
return fi.Mode()&os.ModeSymlink != 0, nil
118+
}

0 commit comments

Comments
 (0)