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
93 changes: 93 additions & 0 deletions internal/platform/fs.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package platform

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

// IsDirWritable reports whether the installer could create files in dir. If dir
// does not exist yet, it checks whether the nearest existing ancestor is
// writable (so dir could be created). It performs filesystem access.
func IsDirWritable(dir string) bool {
if dir == "" {
return false
}
d := dir
for {
info, err := os.Stat(d)
if err == nil {
return info.IsDir() && probeWritable(d)
}
parent := filepath.Dir(d)
if parent == d {
return false // reached the root without finding an existing dir
}
d = parent
}
}

// probeWritable checks writability by creating and removing a temp file.
func probeWritable(dir string) bool {
f, err := os.CreateTemp(dir, ".php-debugger-write-test-")
if err != nil {
return false
}
name := f.Name()
_ = f.Close()
_ = os.Remove(name)
return true
}

// SelectBinDir returns the first writable directory from candidates. It is used
// to choose where the active `php` symlink is placed.
func SelectBinDir(candidates []string) (string, error) {
for _, c := range candidates {
if IsDirWritable(c) {
return c, nil
}
}
if len(candidates) == 0 {
return "", fmt.Errorf("no bin directory candidates")
}
return "", fmt.Errorf("no writable bin directory (tried: %s)", strings.Join(candidates, ", "))
}

// IsOnPATH reports whether dir appears in the given PATH-style string for the
// target OS. Comparison uses ";" on Windows (case-insensitive) and ":" elsewhere
// (case-sensitive). It is pure — it does not read the process environment.
func IsOnPATH(osID OS, dir, pathEnv string) bool {
if dir == "" {
return false
}
sep := ":"
if osID == Windows {
sep = ";"
}
want := normalizePathEntry(osID, dir)
for _, entry := range strings.Split(pathEnv, sep) {
if entry == "" {
continue
}
if normalizePathEntry(osID, entry) == want {
return true
}
}
return false
}

// normalizePathEntry canonicalizes a PATH entry for comparison without relying
// on the host's path separator (so Windows entries compare correctly on Unix
// test hosts).
func normalizePathEntry(osID OS, p string) string {
p = strings.TrimSpace(p)
if osID == Windows {
p = strings.ReplaceAll(p, "\\", "/")
}
p = strings.TrimRight(p, "/")
if osID == Windows {
p = strings.ToLower(p)
}
return p
}
135 changes: 135 additions & 0 deletions internal/platform/fs_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
package platform

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

func TestIsDirWritable(t *testing.T) {
dir := t.TempDir()

if !IsDirWritable(dir) {
t.Errorf("existing temp dir %q should be writable", dir)
}

// A not-yet-existing subdirectory whose parent is writable should count as
// writable (we can create it).
nested := filepath.Join(dir, "a", "b", "c")
if !IsDirWritable(nested) {
t.Errorf("nested path under writable parent %q should be writable", nested)
}

// A path whose ancestor is a file (not a directory) is not writable.
fileParent := filepath.Join(dir, "afile")
if err := os.WriteFile(fileParent, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
if IsDirWritable(filepath.Join(fileParent, "sub")) {
t.Error("path under a regular file should not be writable")
}

if IsDirWritable("") {
t.Error("empty dir should not be writable")
}
}

func TestSelectBinDir(t *testing.T) {
writable := t.TempDir()

// A non-writable candidate: under a regular file.
blocker := filepath.Join(t.TempDir(), "file")
if err := os.WriteFile(blocker, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
badCandidate := filepath.Join(blocker, "bin")

got, err := SelectBinDir([]string{badCandidate, writable})
if err != nil {
t.Fatalf("SelectBinDir: %v", err)
}
if got != writable {
t.Errorf("SelectBinDir picked %q, want %q", got, writable)
}

if _, err := SelectBinDir([]string{badCandidate}); err == nil {
t.Error("SelectBinDir with only bad candidates: expected error")
}
if _, err := SelectBinDir(nil); err == nil {
t.Error("SelectBinDir with no candidates: expected error")
}
}

func TestIsOnPATH(t *testing.T) {
tests := []struct {
name string
osID OS
dir string
pathEnv string
want bool
}{
{
name: "unix present",
osID: Linux,
dir: "/home/jane/.local/bin",
pathEnv: "/usr/bin:/home/jane/.local/bin:/bin",
want: true,
},
{
name: "unix trailing slash normalized",
osID: Linux,
dir: "/home/jane/.local/bin/",
pathEnv: "/usr/bin:/home/jane/.local/bin",
want: true,
},
{
name: "unix absent",
osID: MacOS,
dir: "/opt/homebrew/bin",
pathEnv: "/usr/bin:/bin",
want: false,
},
{
name: "unix case sensitive",
osID: Linux,
dir: "/Home/Jane/bin",
pathEnv: "/home/jane/bin",
want: false,
},
{
name: "windows case-insensitive and slash-agnostic",
osID: Windows,
dir: `C:\Program Files\php-debugger\bin`,
pathEnv: `C:\Windows;c:/program files/PHP-DEBUGGER/BIN`,
want: true,
},
{
name: "windows absent",
osID: Windows,
dir: `C:\a\bin`,
pathEnv: `C:\b\bin;C:\c\bin`,
want: false,
},
{
name: "empty dir",
osID: Linux,
dir: "",
pathEnv: "/usr/bin",
want: false,
},
{
name: "empty path",
osID: Linux,
dir: "/usr/bin",
pathEnv: "",
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := IsOnPATH(tt.osID, tt.dir, tt.pathEnv); got != tt.want {
t.Errorf("IsOnPATH(%s, %q, %q) = %v, want %v", tt.osID, tt.dir, tt.pathEnv, got, tt.want)
}
})
}
}
Loading
Loading