From b023c80c12cbe58bc1df4b1578d3b92445d33265 Mon Sep 17 00:00:00 2001 From: Carlos Granados Date: Thu, 23 Jul 2026 18:41:20 +0200 Subject: [PATCH] Platform package --- internal/platform/fs.go | 93 ++++++++++++ internal/platform/fs_test.go | 135 +++++++++++++++++ internal/platform/paths.go | 195 +++++++++++++++++++++++++ internal/platform/paths_test.go | 227 +++++++++++++++++++++++++++++ internal/platform/platform.go | 86 +++++++++++ internal/platform/platform_test.go | 76 ++++++++++ 6 files changed, 812 insertions(+) create mode 100644 internal/platform/fs.go create mode 100644 internal/platform/fs_test.go create mode 100644 internal/platform/paths.go create mode 100644 internal/platform/paths_test.go create mode 100644 internal/platform/platform.go create mode 100644 internal/platform/platform_test.go diff --git a/internal/platform/fs.go b/internal/platform/fs.go new file mode 100644 index 0000000..8ef7fe1 --- /dev/null +++ b/internal/platform/fs.go @@ -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 +} diff --git a/internal/platform/fs_test.go b/internal/platform/fs_test.go new file mode 100644 index 0000000..7ee7c89 --- /dev/null +++ b/internal/platform/fs_test.go @@ -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) + } + }) + } +} diff --git a/internal/platform/paths.go b/internal/platform/paths.go new file mode 100644 index 0000000..7cee8bc --- /dev/null +++ b/internal/platform/paths.go @@ -0,0 +1,195 @@ +package platform + +import ( + "errors" + "fmt" + "os" + "path/filepath" +) + +// Scope selects where the installer places files. +type Scope int + +const ( + // System is the default, system-wide scope (/opt on Linux/macOS, + // Program Files on Windows). Writing to it generally requires elevation. + System Scope = iota + // User is the per-user scope under the user's home directory (no elevation). + User +) + +func (s Scope) String() string { + switch s { + case System: + return "system" + case User: + return "user" + default: + return "unknown" + } +} + +// ScopeFromUserFlag maps the CLI --user flag to a Scope. +func ScopeFromUserFlag(user bool) Scope { + if user { + return User + } + return System +} + +// Env captures every external input path resolution depends on, so that it can +// be faked in tests. Use CurrentEnv to populate it from the real host. +type Env struct { + OS OS + Arch Arch + // Home is the user's home directory. + Home string + // Getenv resolves environment variables. If nil, os.Getenv is used. + Getenv func(string) string +} + +func (e Env) getenv(key string) string { + if e.Getenv != nil { + return e.Getenv(key) + } + return os.Getenv(key) +} + +// CurrentEnv builds an Env from the host's runtime, home directory and +// environment. +func CurrentEnv() (Env, error) { + p, err := Detect() + if err != nil { + return Env{}, err + } + home, err := os.UserHomeDir() + if err != nil { + return Env{}, fmt.Errorf("determining home directory: %w", err) + } + return Env{OS: p.OS, Arch: p.Arch, Home: home, Getenv: os.Getenv}, nil +} + +// Layout is the set of resolved locations for a given scope. BinCandidates is an +// ordered preference list of directories for the active `php` symlink; the first +// writable one is chosen (see SelectBinDir). +type Layout struct { + Scope Scope + Root string + BinCandidates []string +} + +// ManifestPath is the location of the installer's state file for this layout. +func (l Layout) ManifestPath() string { + return filepath.Join(l.Root, "manifest.json") +} + +// BackupsDir is where replaced interpreters are backed up for this layout. +func (l Layout) BackupsDir() string { + return filepath.Join(l.Root, "backups") +} + +// VersionDirName is the folder name for a given PHP version and threading model: +// "8.3" for NTS, "8.3-zts" for ZTS. The suffix lets both variants coexist. +func VersionDirName(version string, zts bool) string { + if zts { + return version + "-zts" + } + return version +} + +// VersionDir is the absolute install directory for a PHP version/variant. +func (l Layout) VersionDir(version string, zts bool) string { + return filepath.Join(l.Root, VersionDirName(version, zts)) +} + +// Resolve computes the Layout for the given environment and scope. It is pure: +// it performs no filesystem access and never fails on missing directories. +func Resolve(env Env, scope Scope) (Layout, error) { + if env.OS == "" { + return Layout{}, errors.New("platform.Resolve: empty OS") + } + if scope == User && env.Home == "" { + return Layout{}, errors.New("platform.Resolve: user scope requires a home directory") + } + + root := resolveRoot(env, scope) + bins := resolveBinCandidates(env, scope, root) + return Layout{Scope: scope, Root: root, BinCandidates: bins}, nil +} + +func resolveRoot(env Env, scope Scope) string { + if scope == System { + switch env.OS { + case Windows: + return filepath.Join(programFiles(env), AppName) + case MacOS: + // Apple Silicon follows Homebrew's /opt prefix; Intel Macs kept + // /usr/local, like Linux. + if env.Arch == Arm64 { + return filepath.Join("/opt", AppName) + } + return filepath.Join("/usr/local", AppName) + default: // Linux and other Unix + return filepath.Join("/usr/local", AppName) + } + } + + // User scope. + switch env.OS { + case MacOS: + return filepath.Join(env.Home, "Library", "Application Support", AppName) + case Windows: + return filepath.Join(localAppData(env), AppName) + default: // Linux and other Unix + return filepath.Join(xdgDataHome(env), AppName) + } +} + +func resolveBinCandidates(env Env, scope Scope, root string) []string { + if scope == System { + switch env.OS { + case MacOS: + if env.Arch == Arm64 { + // Homebrew's arm64 prefix first, then the Intel/standard location. + return []string{"/opt/homebrew/bin", "/usr/local/bin"} + } + return []string{"/usr/local/bin"} + case Linux: + return []string{"/usr/local/bin"} + case Windows: + return []string{filepath.Join(root, "bin")} + } + } + + // User scope. + switch env.OS { + case Windows: + return []string{filepath.Join(root, "bin")} + default: // Linux, macOS + return []string{filepath.Join(env.Home, ".local", "bin")} + } +} + +// xdgDataHome returns $XDG_DATA_HOME or the ~/.local/share default. +func xdgDataHome(env Env) string { + if v := env.getenv("XDG_DATA_HOME"); v != "" { + return v + } + return filepath.Join(env.Home, ".local", "share") +} + +// localAppData returns %LOCALAPPDATA% or a sensible default under the home dir. +func localAppData(env Env) string { + if v := env.getenv("LOCALAPPDATA"); v != "" { + return v + } + return filepath.Join(env.Home, "AppData", "Local") +} + +// programFiles returns %ProgramFiles% or the conventional default. +func programFiles(env Env) string { + if v := env.getenv("ProgramFiles"); v != "" { + return v + } + return `C:\Program Files` +} diff --git a/internal/platform/paths_test.go b/internal/platform/paths_test.go new file mode 100644 index 0000000..4d032c7 --- /dev/null +++ b/internal/platform/paths_test.go @@ -0,0 +1,227 @@ +package platform + +import ( + "path/filepath" + "testing" +) + +// fakeEnv builds an Env with a controllable environment map. +func fakeEnv(osID OS, arch Arch, home string, vars map[string]string) Env { + return Env{ + OS: osID, + Arch: arch, + Home: home, + Getenv: func(k string) string { + return vars[k] + }, + } +} + +func TestResolveRoot(t *testing.T) { + const home = "/home/jane" + tests := []struct { + name string + env Env + scope Scope + want string + }{ + { + name: "linux system uses /usr/local", + env: fakeEnv(Linux, X8664, home, nil), + scope: System, + want: filepath.Join("/usr/local", AppName), + }, + { + name: "linux arm64 system uses /usr/local", + env: fakeEnv(Linux, Arm64, home, nil), + scope: System, + want: filepath.Join("/usr/local", AppName), + }, + { + name: "macos arm64 system uses /opt", + env: fakeEnv(MacOS, Arm64, home, nil), + scope: System, + want: filepath.Join("/opt", AppName), + }, + { + name: "macos intel system uses /usr/local", + env: fakeEnv(MacOS, X8664, home, nil), + scope: System, + want: filepath.Join("/usr/local", AppName), + }, + { + name: "windows system default ProgramFiles", + env: fakeEnv(Windows, X8664, home, nil), + scope: System, + want: filepath.Join(`C:\Program Files`, AppName), + }, + { + name: "windows system custom ProgramFiles", + env: fakeEnv(Windows, X8664, home, map[string]string{"ProgramFiles": `D:\Apps`}), + scope: System, + want: filepath.Join(`D:\Apps`, AppName), + }, + { + name: "linux user default XDG", + env: fakeEnv(Linux, X8664, home, nil), + scope: User, + want: filepath.Join(home, ".local", "share", AppName), + }, + { + name: "linux user XDG override", + env: fakeEnv(Linux, X8664, home, map[string]string{"XDG_DATA_HOME": "/data/xdg"}), + scope: User, + want: filepath.Join("/data/xdg", AppName), + }, + { + name: "macos user Library", + env: fakeEnv(MacOS, Arm64, home, nil), + scope: User, + want: filepath.Join(home, "Library", "Application Support", AppName), + }, + { + name: "windows user LOCALAPPDATA", + env: fakeEnv(Windows, X8664, home, map[string]string{"LOCALAPPDATA": `C:\Users\jane\AppData\Local`}), + scope: User, + want: filepath.Join(`C:\Users\jane\AppData\Local`, AppName), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + l, err := Resolve(tt.env, tt.scope) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if l.Root != tt.want { + t.Errorf("Root = %q, want %q", l.Root, tt.want) + } + }) + } +} + +func TestResolveBinCandidates(t *testing.T) { + const home = "/home/jane" + tests := []struct { + name string + env Env + scope Scope + want []string + }{ + { + name: "macos arm64 system prefers homebrew", + env: fakeEnv(MacOS, Arm64, home, nil), + scope: System, + want: []string{"/opt/homebrew/bin", "/usr/local/bin"}, + }, + { + name: "macos intel system", + env: fakeEnv(MacOS, X8664, home, nil), + scope: System, + want: []string{"/usr/local/bin"}, + }, + { + name: "linux system", + env: fakeEnv(Linux, Arm64, home, nil), + scope: System, + want: []string{"/usr/local/bin"}, + }, + { + name: "linux user local bin", + env: fakeEnv(Linux, X8664, home, nil), + scope: User, + want: []string{filepath.Join(home, ".local", "bin")}, + }, + { + name: "macos user local bin", + env: fakeEnv(MacOS, Arm64, home, nil), + scope: User, + want: []string{filepath.Join(home, ".local", "bin")}, + }, + { + name: "windows system bin under root", + env: fakeEnv(Windows, X8664, home, nil), + scope: System, + want: []string{filepath.Join(`C:\Program Files`, AppName, "bin")}, + }, + { + name: "windows user bin under root", + env: fakeEnv(Windows, X8664, home, map[string]string{"LOCALAPPDATA": `C:\LA`}), + scope: User, + want: []string{filepath.Join(`C:\LA`, AppName, "bin")}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + l, err := Resolve(tt.env, tt.scope) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if !equalStrings(l.BinCandidates, tt.want) { + t.Errorf("BinCandidates = %v, want %v", l.BinCandidates, tt.want) + } + }) + } +} + +func TestResolveErrors(t *testing.T) { + if _, err := Resolve(Env{OS: "", Arch: X8664}, System); err == nil { + t.Error("Resolve with empty OS: expected error") + } + if _, err := Resolve(Env{OS: Linux, Arch: X8664, Home: ""}, User); err == nil { + t.Error("Resolve user scope without home: expected error") + } + // System scope does not require a home directory. + if _, err := Resolve(Env{OS: Linux, Arch: X8664, Home: ""}, System); err != nil { + t.Errorf("Resolve system scope without home: unexpected error: %v", err) + } +} + +func TestLayoutDerivedPaths(t *testing.T) { + l, err := Resolve(fakeEnv(Linux, X8664, "/home/jane", nil), User) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + wantRoot := filepath.Join("/home/jane", ".local", "share", AppName) + if got := l.ManifestPath(); got != filepath.Join(wantRoot, "manifest.json") { + t.Errorf("ManifestPath = %q", got) + } + if got := l.BackupsDir(); got != filepath.Join(wantRoot, "backups") { + t.Errorf("BackupsDir = %q", got) + } + if got := l.VersionDir("8.3", false); got != filepath.Join(wantRoot, "8.3") { + t.Errorf("VersionDir NTS = %q", got) + } + if got := l.VersionDir("8.3", true); got != filepath.Join(wantRoot, "8.3-zts") { + t.Errorf("VersionDir ZTS = %q", got) + } +} + +func TestVersionDirName(t *testing.T) { + if got := VersionDirName("8.2", false); got != "8.2" { + t.Errorf("NTS = %q, want 8.2", got) + } + if got := VersionDirName("8.2", true); got != "8.2-zts" { + t.Errorf("ZTS = %q, want 8.2-zts", got) + } +} + +func TestScopeFromUserFlag(t *testing.T) { + if ScopeFromUserFlag(true) != User { + t.Error("ScopeFromUserFlag(true) should be User") + } + if ScopeFromUserFlag(false) != System { + t.Error("ScopeFromUserFlag(false) should be System") + } +} + +func equalStrings(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/internal/platform/platform.go b/internal/platform/platform.go new file mode 100644 index 0000000..e13cbdc --- /dev/null +++ b/internal/platform/platform.go @@ -0,0 +1,86 @@ +// Package platform handles OS/architecture detection and resolves the +// filesystem locations the installer uses, for both the system-wide and +// per-user install scopes. +// +// The core logic is pure: OS/arch mapping and path resolution take their inputs +// (GOOS, GOARCH, home dir, environment) as parameters so they can be unit tested +// for any target platform from any host. Thin wrappers (Detect, CurrentEnv) +// supply the real runtime values. +package platform + +import ( + "fmt" + "runtime" +) + +// OS is the operating-system token used in release asset names. +type OS string + +const ( + Linux OS = "linux" + MacOS OS = "macos" + Windows OS = "windows" +) + +// Arch is the CPU-architecture token used in release asset names. +type Arch string + +const ( + X8664 Arch = "x86_64" + Arm64 Arch = "arm64" +) + +// AppName is the directory/binary name used throughout the installer. +const AppName = "php-debugger" + +// Platform is a resolved OS/architecture pair. +type Platform struct { + OS OS + Arch Arch +} + +func (p Platform) String() string { return string(p.OS) + "/" + string(p.Arch) } + +// DetectOS maps a Go GOOS value to a release OS token. +func DetectOS(goos string) (OS, error) { + switch goos { + case "linux": + return Linux, nil + case "darwin": + return MacOS, nil + case "windows": + return Windows, nil + default: + return "", fmt.Errorf("unsupported operating system %q", goos) + } +} + +// DetectArch maps a Go GOARCH value to a release architecture token. +func DetectArch(goarch string) (Arch, error) { + switch goarch { + case "amd64": + return X8664, nil + case "arm64": + return Arm64, nil + default: + return "", fmt.Errorf("unsupported architecture %q", goarch) + } +} + +// DetectFor resolves a Platform from explicit GOOS/GOARCH values (for testing). +func DetectFor(goos, goarch string) (Platform, error) { + osID, err := DetectOS(goos) + if err != nil { + return Platform{}, err + } + arch, err := DetectArch(goarch) + if err != nil { + return Platform{}, err + } + return Platform{OS: osID, Arch: arch}, nil +} + +// Detect resolves the Platform of the host the installer is running on. +func Detect() (Platform, error) { + return DetectFor(runtime.GOOS, runtime.GOARCH) +} diff --git a/internal/platform/platform_test.go b/internal/platform/platform_test.go new file mode 100644 index 0000000..c59a73e --- /dev/null +++ b/internal/platform/platform_test.go @@ -0,0 +1,76 @@ +package platform + +import "testing" + +func TestDetectOS(t *testing.T) { + tests := []struct { + goos string + want OS + wantErr bool + }{ + {"linux", Linux, false}, + {"darwin", MacOS, false}, + {"windows", Windows, false}, + {"freebsd", "", true}, + {"", "", true}, + } + for _, tt := range tests { + got, err := DetectOS(tt.goos) + if tt.wantErr { + if err == nil { + t.Errorf("DetectOS(%q): expected error, got %q", tt.goos, got) + } + continue + } + if err != nil { + t.Errorf("DetectOS(%q): unexpected error: %v", tt.goos, err) + } + if got != tt.want { + t.Errorf("DetectOS(%q) = %q, want %q", tt.goos, got, tt.want) + } + } +} + +func TestDetectArch(t *testing.T) { + tests := []struct { + goarch string + want Arch + wantErr bool + }{ + {"amd64", X8664, false}, + {"arm64", Arm64, false}, + {"386", "", true}, + {"", "", true}, + } + for _, tt := range tests { + got, err := DetectArch(tt.goarch) + if tt.wantErr { + if err == nil { + t.Errorf("DetectArch(%q): expected error, got %q", tt.goarch, got) + } + continue + } + if err != nil { + t.Errorf("DetectArch(%q): unexpected error: %v", tt.goarch, err) + } + if got != tt.want { + t.Errorf("DetectArch(%q) = %q, want %q", tt.goarch, got, tt.want) + } + } +} + +func TestDetectFor(t *testing.T) { + got, err := DetectFor("darwin", "arm64") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.OS != MacOS || got.Arch != Arm64 { + t.Errorf("DetectFor(darwin, arm64) = %v, want macos/arm64", got) + } + if _, err := DetectFor("plan9", "amd64"); err == nil { + t.Error("DetectFor(plan9, amd64): expected error") + } + if _, err := DetectFor("linux", "mips"); err == nil { + t.Error("DetectFor(linux, mips): expected error") + } +}