Skip to content

Commit ab2ed90

Browse files
Platform package (#2)
1 parent 4340cb2 commit ab2ed90

6 files changed

Lines changed: 812 additions & 0 deletions

File tree

‎internal/platform/fs.go‎

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
package platform
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"path/filepath"
7+
"strings"
8+
)
9+
10+
// IsDirWritable reports whether the installer could create files in dir. If dir
11+
// does not exist yet, it checks whether the nearest existing ancestor is
12+
// writable (so dir could be created). It performs filesystem access.
13+
func IsDirWritable(dir string) bool {
14+
if dir == "" {
15+
return false
16+
}
17+
d := dir
18+
for {
19+
info, err := os.Stat(d)
20+
if err == nil {
21+
return info.IsDir() && probeWritable(d)
22+
}
23+
parent := filepath.Dir(d)
24+
if parent == d {
25+
return false // reached the root without finding an existing dir
26+
}
27+
d = parent
28+
}
29+
}
30+
31+
// probeWritable checks writability by creating and removing a temp file.
32+
func probeWritable(dir string) bool {
33+
f, err := os.CreateTemp(dir, ".php-debugger-write-test-")
34+
if err != nil {
35+
return false
36+
}
37+
name := f.Name()
38+
_ = f.Close()
39+
_ = os.Remove(name)
40+
return true
41+
}
42+
43+
// SelectBinDir returns the first writable directory from candidates. It is used
44+
// to choose where the active `php` symlink is placed.
45+
func SelectBinDir(candidates []string) (string, error) {
46+
for _, c := range candidates {
47+
if IsDirWritable(c) {
48+
return c, nil
49+
}
50+
}
51+
if len(candidates) == 0 {
52+
return "", fmt.Errorf("no bin directory candidates")
53+
}
54+
return "", fmt.Errorf("no writable bin directory (tried: %s)", strings.Join(candidates, ", "))
55+
}
56+
57+
// IsOnPATH reports whether dir appears in the given PATH-style string for the
58+
// target OS. Comparison uses ";" on Windows (case-insensitive) and ":" elsewhere
59+
// (case-sensitive). It is pure — it does not read the process environment.
60+
func IsOnPATH(osID OS, dir, pathEnv string) bool {
61+
if dir == "" {
62+
return false
63+
}
64+
sep := ":"
65+
if osID == Windows {
66+
sep = ";"
67+
}
68+
want := normalizePathEntry(osID, dir)
69+
for _, entry := range strings.Split(pathEnv, sep) {
70+
if entry == "" {
71+
continue
72+
}
73+
if normalizePathEntry(osID, entry) == want {
74+
return true
75+
}
76+
}
77+
return false
78+
}
79+
80+
// normalizePathEntry canonicalizes a PATH entry for comparison without relying
81+
// on the host's path separator (so Windows entries compare correctly on Unix
82+
// test hosts).
83+
func normalizePathEntry(osID OS, p string) string {
84+
p = strings.TrimSpace(p)
85+
if osID == Windows {
86+
p = strings.ReplaceAll(p, "\\", "/")
87+
}
88+
p = strings.TrimRight(p, "/")
89+
if osID == Windows {
90+
p = strings.ToLower(p)
91+
}
92+
return p
93+
}

‎internal/platform/fs_test.go‎

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
package platform
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
)
8+
9+
func TestIsDirWritable(t *testing.T) {
10+
dir := t.TempDir()
11+
12+
if !IsDirWritable(dir) {
13+
t.Errorf("existing temp dir %q should be writable", dir)
14+
}
15+
16+
// A not-yet-existing subdirectory whose parent is writable should count as
17+
// writable (we can create it).
18+
nested := filepath.Join(dir, "a", "b", "c")
19+
if !IsDirWritable(nested) {
20+
t.Errorf("nested path under writable parent %q should be writable", nested)
21+
}
22+
23+
// A path whose ancestor is a file (not a directory) is not writable.
24+
fileParent := filepath.Join(dir, "afile")
25+
if err := os.WriteFile(fileParent, []byte("x"), 0o644); err != nil {
26+
t.Fatal(err)
27+
}
28+
if IsDirWritable(filepath.Join(fileParent, "sub")) {
29+
t.Error("path under a regular file should not be writable")
30+
}
31+
32+
if IsDirWritable("") {
33+
t.Error("empty dir should not be writable")
34+
}
35+
}
36+
37+
func TestSelectBinDir(t *testing.T) {
38+
writable := t.TempDir()
39+
40+
// A non-writable candidate: under a regular file.
41+
blocker := filepath.Join(t.TempDir(), "file")
42+
if err := os.WriteFile(blocker, []byte("x"), 0o644); err != nil {
43+
t.Fatal(err)
44+
}
45+
badCandidate := filepath.Join(blocker, "bin")
46+
47+
got, err := SelectBinDir([]string{badCandidate, writable})
48+
if err != nil {
49+
t.Fatalf("SelectBinDir: %v", err)
50+
}
51+
if got != writable {
52+
t.Errorf("SelectBinDir picked %q, want %q", got, writable)
53+
}
54+
55+
if _, err := SelectBinDir([]string{badCandidate}); err == nil {
56+
t.Error("SelectBinDir with only bad candidates: expected error")
57+
}
58+
if _, err := SelectBinDir(nil); err == nil {
59+
t.Error("SelectBinDir with no candidates: expected error")
60+
}
61+
}
62+
63+
func TestIsOnPATH(t *testing.T) {
64+
tests := []struct {
65+
name string
66+
osID OS
67+
dir string
68+
pathEnv string
69+
want bool
70+
}{
71+
{
72+
name: "unix present",
73+
osID: Linux,
74+
dir: "/home/jane/.local/bin",
75+
pathEnv: "/usr/bin:/home/jane/.local/bin:/bin",
76+
want: true,
77+
},
78+
{
79+
name: "unix trailing slash normalized",
80+
osID: Linux,
81+
dir: "/home/jane/.local/bin/",
82+
pathEnv: "/usr/bin:/home/jane/.local/bin",
83+
want: true,
84+
},
85+
{
86+
name: "unix absent",
87+
osID: MacOS,
88+
dir: "/opt/homebrew/bin",
89+
pathEnv: "/usr/bin:/bin",
90+
want: false,
91+
},
92+
{
93+
name: "unix case sensitive",
94+
osID: Linux,
95+
dir: "/Home/Jane/bin",
96+
pathEnv: "/home/jane/bin",
97+
want: false,
98+
},
99+
{
100+
name: "windows case-insensitive and slash-agnostic",
101+
osID: Windows,
102+
dir: `C:\Program Files\php-debugger\bin`,
103+
pathEnv: `C:\Windows;c:/program files/PHP-DEBUGGER/BIN`,
104+
want: true,
105+
},
106+
{
107+
name: "windows absent",
108+
osID: Windows,
109+
dir: `C:\a\bin`,
110+
pathEnv: `C:\b\bin;C:\c\bin`,
111+
want: false,
112+
},
113+
{
114+
name: "empty dir",
115+
osID: Linux,
116+
dir: "",
117+
pathEnv: "/usr/bin",
118+
want: false,
119+
},
120+
{
121+
name: "empty path",
122+
osID: Linux,
123+
dir: "/usr/bin",
124+
pathEnv: "",
125+
want: false,
126+
},
127+
}
128+
for _, tt := range tests {
129+
t.Run(tt.name, func(t *testing.T) {
130+
if got := IsOnPATH(tt.osID, tt.dir, tt.pathEnv); got != tt.want {
131+
t.Errorf("IsOnPATH(%s, %q, %q) = %v, want %v", tt.osID, tt.dir, tt.pathEnv, got, tt.want)
132+
}
133+
})
134+
}
135+
}

0 commit comments

Comments
 (0)