From a2e92962358049607481bc84e98e177a31fdc3bb Mon Sep 17 00:00:00 2001 From: Carlos Granados Date: Sat, 25 Jul 2026 08:40:30 +0200 Subject: [PATCH] Symlink layer --- internal/platform/link.go | 160 +++++++++++++++++++++++++++ internal/platform/link_test.go | 194 +++++++++++++++++++++++++++++++++ 2 files changed, 354 insertions(+) create mode 100644 internal/platform/link.go create mode 100644 internal/platform/link_test.go diff --git a/internal/platform/link.go b/internal/platform/link.go new file mode 100644 index 0000000..f67e058 --- /dev/null +++ b/internal/platform/link.go @@ -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") +} diff --git a/internal/platform/link_test.go b/internal/platform/link_test.go new file mode 100644 index 0000000..88676d8 --- /dev/null +++ b/internal/platform/link_test.go @@ -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") + } +}