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

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

// ActiveKind describes how an active interpreter entry is materialized.
type ActiveKind int

const (
// KindSymlink is a real symbolic link (Unix always; Windows when permitted).
KindSymlink ActiveKind = iota
// KindShim is a generated .cmd wrapper used on Windows when a symlink cannot
// be created (symlinks there need Developer Mode or admin rights).
KindShim
)

func (k ActiveKind) String() string {
if k == KindShim {
return "shim"
}
return "symlink"
}

// Activate makes the command `name` in binDir launch `target`, atomically
// replacing any existing entry. On Unix it creates a symlink; on Windows it
// tries a symlink first and falls back to a .cmd shim. It returns the path it
// created and how it was materialized.
func Activate(binDir, name, target string) (string, ActiveKind, error) {
if err := os.MkdirAll(binDir, 0o755); err != nil {
return "", 0, fmt.Errorf("creating bin dir %s: %w", binDir, err)
}
if runtime.GOOS == "windows" {
return activateWindows(binDir, name, target)
}
linkPath := filepath.Join(binDir, name)
if err := replaceSymlink(target, linkPath); err != nil {
return "", 0, err
}
return linkPath, KindSymlink, nil
}

func activateWindows(binDir, name, target string) (string, ActiveKind, error) {
exePath := filepath.Join(binDir, name+".exe")
cmdPath := filepath.Join(binDir, name+".cmd")

// Prefer a real symlink; if it works, clear any stale shim.
if err := replaceSymlink(target, exePath); err == nil {
_ = os.Remove(cmdPath)
return exePath, KindSymlink, nil
}
// Symlink not permitted: write a .cmd shim and clear any stale symlink.
if err := writeShim(cmdPath, target); err != nil {
return "", 0, err
}
_ = os.Remove(exePath)
return cmdPath, KindShim, nil
}

// ReadActive returns the target the command `name` in binDir currently launches
// and how it is materialized. ok is false when there is no active entry.
func ReadActive(binDir, name string) (target string, kind ActiveKind, ok bool) {
if runtime.GOOS == "windows" {
if t, err := os.Readlink(filepath.Join(binDir, name+".exe")); err == nil {
return t, KindSymlink, true
}
if t, err := readShimTarget(filepath.Join(binDir, name+".cmd")); err == nil {
return t, KindShim, true
}
return "", 0, false
}
if t, err := os.Readlink(filepath.Join(binDir, name)); err == nil {
return t, KindSymlink, true
}
return "", 0, false
}

// RemoveActive removes the active entry for `name` in binDir. It is idempotent:
// a missing entry is not an error.
func RemoveActive(binDir, name string) error {
var paths []string
if runtime.GOOS == "windows" {
paths = []string{filepath.Join(binDir, name+".exe"), filepath.Join(binDir, name+".cmd")}
} else {
paths = []string{filepath.Join(binDir, name)}
}
var firstErr error
for _, p := range paths {
if err := os.Remove(p); err != nil && !os.IsNotExist(err) && firstErr == nil {
firstErr = err
}
}
return firstErr
}

// IsSymlink reports whether path is a symbolic link.
func IsSymlink(path string) (bool, error) {
fi, err := os.Lstat(path)
if err != nil {
return false, err
}
return fi.Mode()&os.ModeSymlink != 0, nil
}

// replaceSymlink atomically creates (or replaces) a symlink at linkPath pointing
// to target, by creating a uniquely-named temp symlink and renaming it into
// place. The rename is atomic on Unix, so `php` is never momentarily absent.
func replaceSymlink(target, linkPath string) error {
tmp := fmt.Sprintf("%s.tmp-%d", linkPath, time.Now().UnixNano())
if err := os.Symlink(target, tmp); err != nil {
return fmt.Errorf("creating symlink: %w", err)
}
if err := os.Rename(tmp, linkPath); err != nil {
_ = os.Remove(tmp)
return fmt.Errorf("replacing %s: %w", linkPath, err)
}
return nil
}

func writeShim(shimPath, target string) error {
if err := os.WriteFile(shimPath, []byte(windowsShimContent(target)), 0o755); err != nil {
return fmt.Errorf("writing shim %s: %w", shimPath, err)
}
return nil
}

// windowsShimContent builds a .cmd wrapper that execs target, forwarding all
// arguments (%*). Uses CRLF line endings as is conventional for .cmd files.
func windowsShimContent(target string) string {
return "@echo off\r\n\"" + target + "\" %*\r\n"
}

func readShimTarget(shimPath string) (string, error) {
data, err := os.ReadFile(shimPath)
if err != nil {
return "", err
}
return parseShimTarget(string(data))
}

// parseShimTarget extracts the quoted target path from a generated shim.
func parseShimTarget(content string) (string, error) {
for _, line := range strings.Split(content, "\n") {
line = strings.TrimSpace(strings.TrimRight(line, "\r"))
if line == "" || strings.HasPrefix(line, "@") {
continue
}
if strings.HasPrefix(line, "\"") {
if end := strings.IndexByte(line[1:], '"'); end >= 0 {
return line[1 : 1+end], nil
}
}
}
return "", fmt.Errorf("no target found in shim")
}
194 changes: 194 additions & 0 deletions internal/platform/link_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
package platform

import (
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
)

// writeExecutable creates a file at path with the given content, marked
// executable.
func writeExecutable(t *testing.T, path, content string) {
t.Helper()
if err := os.WriteFile(path, []byte(content), 0o755); err != nil {
t.Fatal(err)
}
}

func TestActivateSymlinkUnix(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Unix symlink semantics")
}
dir := t.TempDir()
binDir := filepath.Join(dir, "bin")
target := filepath.Join(dir, "8.3", "php")
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
t.Fatal(err)
}
writeExecutable(t, target, "#!/bin/sh\necho hi\n")

path, kind, err := Activate(binDir, "php", target)
if err != nil {
t.Fatalf("Activate: %v", err)
}
if kind != KindSymlink {
t.Errorf("kind = %v, want symlink", kind)
}
if path != filepath.Join(binDir, "php") {
t.Errorf("path = %q", path)
}
if isLink, _ := IsSymlink(path); !isLink {
t.Error("activated path should be a symlink")
}
gotTarget, gotKind, ok := ReadActive(binDir, "php")
if !ok || gotTarget != target || gotKind != KindSymlink {
t.Errorf("ReadActive = (%q, %v, %v), want (%q, symlink, true)", gotTarget, gotKind, ok, target)
}
}

func TestActivateReplacesExisting(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Unix symlink semantics")
}
dir := t.TempDir()
binDir := filepath.Join(dir, "bin")
t1 := filepath.Join(dir, "t1")
t2 := filepath.Join(dir, "t2")
writeExecutable(t, t1, "#!/bin/sh\necho one\n")
writeExecutable(t, t2, "#!/bin/sh\necho two\n")

if _, _, err := Activate(binDir, "php", t1); err != nil {
t.Fatal(err)
}
if _, _, err := Activate(binDir, "php", t2); err != nil {
t.Fatalf("re-activate: %v", err)
}
got, _, ok := ReadActive(binDir, "php")
if !ok || got != t2 {
t.Errorf("after replace, target = %q, want %q", got, t2)
}
}

func TestActivateOverRegularFile(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Unix symlink semantics")
}
dir := t.TempDir()
binDir := filepath.Join(dir, "bin")
if err := os.MkdirAll(binDir, 0o755); err != nil {
t.Fatal(err)
}
// A pre-existing real file where the active link will go.
existing := filepath.Join(binDir, "php")
writeExecutable(t, existing, "#!/bin/sh\necho old\n")

target := filepath.Join(dir, "php-new")
writeExecutable(t, target, "#!/bin/sh\necho new\n")

if _, _, err := Activate(binDir, "php", target); err != nil {
t.Fatalf("Activate over regular file: %v", err)
}
if isLink, _ := IsSymlink(existing); !isLink {
t.Error("regular file should have been replaced by a symlink")
}
}

func TestActivatedSymlinkRuns(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("uses a /bin/sh target")
}
dir := t.TempDir()
binDir := filepath.Join(dir, "bin")
target := filepath.Join(dir, "php")
writeExecutable(t, target, "#!/bin/sh\necho ACTIVATED\n")

path, _, err := Activate(binDir, "php", target)
if err != nil {
t.Fatal(err)
}
out, err := exec.Command(path).Output()
if err != nil {
t.Fatalf("running activated symlink: %v", err)
}
if got := string(out); got != "ACTIVATED\n" {
t.Errorf("output = %q, want ACTIVATED", got)
}
}

func TestRemoveActive(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Unix symlink semantics")
}
dir := t.TempDir()
binDir := filepath.Join(dir, "bin")
target := filepath.Join(dir, "php")
writeExecutable(t, target, "#!/bin/sh\n")

if _, _, err := Activate(binDir, "php", target); err != nil {
t.Fatal(err)
}
if err := RemoveActive(binDir, "php"); err != nil {
t.Fatalf("RemoveActive: %v", err)
}
if _, _, ok := ReadActive(binDir, "php"); ok {
t.Error("active entry should be gone after RemoveActive")
}
// idempotent
if err := RemoveActive(binDir, "php"); err != nil {
t.Errorf("RemoveActive on missing entry should be nil, got %v", err)
}
}

func TestReadActiveMissing(t *testing.T) {
dir := t.TempDir()
if _, _, ok := ReadActive(dir, "php"); ok {
t.Error("ReadActive on empty dir should be ok=false")
}
}

// --- pure shim tests (run on all platforms) ---

func TestWindowsShimContent(t *testing.T) {
c := windowsShimContent(`C:\opt\php-debugger\8.3\bin\php.exe`)
if !strings.Contains(c, `"C:\opt\php-debugger\8.3\bin\php.exe" %*`) {
t.Errorf("shim content missing quoted target + args:\n%s", c)
}
if !strings.Contains(c, "@echo off") {
t.Errorf("shim content missing @echo off:\n%s", c)
}
}

func TestParseShimTargetRoundTrip(t *testing.T) {
target := `C:\Program Files\php-debugger\8.3\bin\php.exe`
got, err := parseShimTarget(windowsShimContent(target))
if err != nil {
t.Fatalf("parseShimTarget: %v", err)
}
if got != target {
t.Errorf("parsed target = %q, want %q", got, target)
}
}

func TestWriteAndReadShim(t *testing.T) {
shimPath := filepath.Join(t.TempDir(), "php.cmd")
target := `C:\opt\php-debugger\8.4-zts\bin\php.exe`
if err := writeShim(shimPath, target); err != nil {
t.Fatalf("writeShim: %v", err)
}
got, err := readShimTarget(shimPath)
if err != nil {
t.Fatalf("readShimTarget: %v", err)
}
if got != target {
t.Errorf("read target = %q, want %q", got, target)
}
}

func TestParseShimTargetError(t *testing.T) {
if _, err := parseShimTarget("@echo off\r\nnot a quoted target\r\n"); err == nil {
t.Error("expected error parsing shim with no quoted target")
}
}
Loading