Skip to content

Commit a810c72

Browse files
Symlink layer (#7)
1 parent a7b715f commit a810c72

2 files changed

Lines changed: 354 additions & 0 deletions

File tree

internal/platform/link.go

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
package platform
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"path/filepath"
7+
"runtime"
8+
"strings"
9+
"time"
10+
)
11+
12+
// ActiveKind describes how an active interpreter entry is materialized.
13+
type ActiveKind int
14+
15+
const (
16+
// KindSymlink is a real symbolic link (Unix always; Windows when permitted).
17+
KindSymlink ActiveKind = iota
18+
// KindShim is a generated .cmd wrapper used on Windows when a symlink cannot
19+
// be created (symlinks there need Developer Mode or admin rights).
20+
KindShim
21+
)
22+
23+
func (k ActiveKind) String() string {
24+
if k == KindShim {
25+
return "shim"
26+
}
27+
return "symlink"
28+
}
29+
30+
// Activate makes the command `name` in binDir launch `target`, atomically
31+
// replacing any existing entry. On Unix it creates a symlink; on Windows it
32+
// tries a symlink first and falls back to a .cmd shim. It returns the path it
33+
// created and how it was materialized.
34+
func Activate(binDir, name, target string) (string, ActiveKind, error) {
35+
if err := os.MkdirAll(binDir, 0o755); err != nil {
36+
return "", 0, fmt.Errorf("creating bin dir %s: %w", binDir, err)
37+
}
38+
if runtime.GOOS == "windows" {
39+
return activateWindows(binDir, name, target)
40+
}
41+
linkPath := filepath.Join(binDir, name)
42+
if err := replaceSymlink(target, linkPath); err != nil {
43+
return "", 0, err
44+
}
45+
return linkPath, KindSymlink, nil
46+
}
47+
48+
func activateWindows(binDir, name, target string) (string, ActiveKind, error) {
49+
exePath := filepath.Join(binDir, name+".exe")
50+
cmdPath := filepath.Join(binDir, name+".cmd")
51+
52+
// Prefer a real symlink; if it works, clear any stale shim.
53+
if err := replaceSymlink(target, exePath); err == nil {
54+
_ = os.Remove(cmdPath)
55+
return exePath, KindSymlink, nil
56+
}
57+
// Symlink not permitted: write a .cmd shim and clear any stale symlink.
58+
if err := writeShim(cmdPath, target); err != nil {
59+
return "", 0, err
60+
}
61+
_ = os.Remove(exePath)
62+
return cmdPath, KindShim, nil
63+
}
64+
65+
// ReadActive returns the target the command `name` in binDir currently launches
66+
// and how it is materialized. ok is false when there is no active entry.
67+
func ReadActive(binDir, name string) (target string, kind ActiveKind, ok bool) {
68+
if runtime.GOOS == "windows" {
69+
if t, err := os.Readlink(filepath.Join(binDir, name+".exe")); err == nil {
70+
return t, KindSymlink, true
71+
}
72+
if t, err := readShimTarget(filepath.Join(binDir, name+".cmd")); err == nil {
73+
return t, KindShim, true
74+
}
75+
return "", 0, false
76+
}
77+
if t, err := os.Readlink(filepath.Join(binDir, name)); err == nil {
78+
return t, KindSymlink, true
79+
}
80+
return "", 0, false
81+
}
82+
83+
// RemoveActive removes the active entry for `name` in binDir. It is idempotent:
84+
// a missing entry is not an error.
85+
func RemoveActive(binDir, name string) error {
86+
var paths []string
87+
if runtime.GOOS == "windows" {
88+
paths = []string{filepath.Join(binDir, name+".exe"), filepath.Join(binDir, name+".cmd")}
89+
} else {
90+
paths = []string{filepath.Join(binDir, name)}
91+
}
92+
var firstErr error
93+
for _, p := range paths {
94+
if err := os.Remove(p); err != nil && !os.IsNotExist(err) && firstErr == nil {
95+
firstErr = err
96+
}
97+
}
98+
return firstErr
99+
}
100+
101+
// IsSymlink reports whether path is a symbolic link.
102+
func IsSymlink(path string) (bool, error) {
103+
fi, err := os.Lstat(path)
104+
if err != nil {
105+
return false, err
106+
}
107+
return fi.Mode()&os.ModeSymlink != 0, nil
108+
}
109+
110+
// replaceSymlink atomically creates (or replaces) a symlink at linkPath pointing
111+
// to target, by creating a uniquely-named temp symlink and renaming it into
112+
// place. The rename is atomic on Unix, so `php` is never momentarily absent.
113+
func replaceSymlink(target, linkPath string) error {
114+
tmp := fmt.Sprintf("%s.tmp-%d", linkPath, time.Now().UnixNano())
115+
if err := os.Symlink(target, tmp); err != nil {
116+
return fmt.Errorf("creating symlink: %w", err)
117+
}
118+
if err := os.Rename(tmp, linkPath); err != nil {
119+
_ = os.Remove(tmp)
120+
return fmt.Errorf("replacing %s: %w", linkPath, err)
121+
}
122+
return nil
123+
}
124+
125+
func writeShim(shimPath, target string) error {
126+
if err := os.WriteFile(shimPath, []byte(windowsShimContent(target)), 0o755); err != nil {
127+
return fmt.Errorf("writing shim %s: %w", shimPath, err)
128+
}
129+
return nil
130+
}
131+
132+
// windowsShimContent builds a .cmd wrapper that execs target, forwarding all
133+
// arguments (%*). Uses CRLF line endings as is conventional for .cmd files.
134+
func windowsShimContent(target string) string {
135+
return "@echo off\r\n\"" + target + "\" %*\r\n"
136+
}
137+
138+
func readShimTarget(shimPath string) (string, error) {
139+
data, err := os.ReadFile(shimPath)
140+
if err != nil {
141+
return "", err
142+
}
143+
return parseShimTarget(string(data))
144+
}
145+
146+
// parseShimTarget extracts the quoted target path from a generated shim.
147+
func parseShimTarget(content string) (string, error) {
148+
for _, line := range strings.Split(content, "\n") {
149+
line = strings.TrimSpace(strings.TrimRight(line, "\r"))
150+
if line == "" || strings.HasPrefix(line, "@") {
151+
continue
152+
}
153+
if strings.HasPrefix(line, "\"") {
154+
if end := strings.IndexByte(line[1:], '"'); end >= 0 {
155+
return line[1 : 1+end], nil
156+
}
157+
}
158+
}
159+
return "", fmt.Errorf("no target found in shim")
160+
}

internal/platform/link_test.go

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
package platform
2+
3+
import (
4+
"os"
5+
"os/exec"
6+
"path/filepath"
7+
"runtime"
8+
"strings"
9+
"testing"
10+
)
11+
12+
// writeExecutable creates a file at path with the given content, marked
13+
// executable.
14+
func writeExecutable(t *testing.T, path, content string) {
15+
t.Helper()
16+
if err := os.WriteFile(path, []byte(content), 0o755); err != nil {
17+
t.Fatal(err)
18+
}
19+
}
20+
21+
func TestActivateSymlinkUnix(t *testing.T) {
22+
if runtime.GOOS == "windows" {
23+
t.Skip("Unix symlink semantics")
24+
}
25+
dir := t.TempDir()
26+
binDir := filepath.Join(dir, "bin")
27+
target := filepath.Join(dir, "8.3", "php")
28+
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
29+
t.Fatal(err)
30+
}
31+
writeExecutable(t, target, "#!/bin/sh\necho hi\n")
32+
33+
path, kind, err := Activate(binDir, "php", target)
34+
if err != nil {
35+
t.Fatalf("Activate: %v", err)
36+
}
37+
if kind != KindSymlink {
38+
t.Errorf("kind = %v, want symlink", kind)
39+
}
40+
if path != filepath.Join(binDir, "php") {
41+
t.Errorf("path = %q", path)
42+
}
43+
if isLink, _ := IsSymlink(path); !isLink {
44+
t.Error("activated path should be a symlink")
45+
}
46+
gotTarget, gotKind, ok := ReadActive(binDir, "php")
47+
if !ok || gotTarget != target || gotKind != KindSymlink {
48+
t.Errorf("ReadActive = (%q, %v, %v), want (%q, symlink, true)", gotTarget, gotKind, ok, target)
49+
}
50+
}
51+
52+
func TestActivateReplacesExisting(t *testing.T) {
53+
if runtime.GOOS == "windows" {
54+
t.Skip("Unix symlink semantics")
55+
}
56+
dir := t.TempDir()
57+
binDir := filepath.Join(dir, "bin")
58+
t1 := filepath.Join(dir, "t1")
59+
t2 := filepath.Join(dir, "t2")
60+
writeExecutable(t, t1, "#!/bin/sh\necho one\n")
61+
writeExecutable(t, t2, "#!/bin/sh\necho two\n")
62+
63+
if _, _, err := Activate(binDir, "php", t1); err != nil {
64+
t.Fatal(err)
65+
}
66+
if _, _, err := Activate(binDir, "php", t2); err != nil {
67+
t.Fatalf("re-activate: %v", err)
68+
}
69+
got, _, ok := ReadActive(binDir, "php")
70+
if !ok || got != t2 {
71+
t.Errorf("after replace, target = %q, want %q", got, t2)
72+
}
73+
}
74+
75+
func TestActivateOverRegularFile(t *testing.T) {
76+
if runtime.GOOS == "windows" {
77+
t.Skip("Unix symlink semantics")
78+
}
79+
dir := t.TempDir()
80+
binDir := filepath.Join(dir, "bin")
81+
if err := os.MkdirAll(binDir, 0o755); err != nil {
82+
t.Fatal(err)
83+
}
84+
// A pre-existing real file where the active link will go.
85+
existing := filepath.Join(binDir, "php")
86+
writeExecutable(t, existing, "#!/bin/sh\necho old\n")
87+
88+
target := filepath.Join(dir, "php-new")
89+
writeExecutable(t, target, "#!/bin/sh\necho new\n")
90+
91+
if _, _, err := Activate(binDir, "php", target); err != nil {
92+
t.Fatalf("Activate over regular file: %v", err)
93+
}
94+
if isLink, _ := IsSymlink(existing); !isLink {
95+
t.Error("regular file should have been replaced by a symlink")
96+
}
97+
}
98+
99+
func TestActivatedSymlinkRuns(t *testing.T) {
100+
if runtime.GOOS == "windows" {
101+
t.Skip("uses a /bin/sh target")
102+
}
103+
dir := t.TempDir()
104+
binDir := filepath.Join(dir, "bin")
105+
target := filepath.Join(dir, "php")
106+
writeExecutable(t, target, "#!/bin/sh\necho ACTIVATED\n")
107+
108+
path, _, err := Activate(binDir, "php", target)
109+
if err != nil {
110+
t.Fatal(err)
111+
}
112+
out, err := exec.Command(path).Output()
113+
if err != nil {
114+
t.Fatalf("running activated symlink: %v", err)
115+
}
116+
if got := string(out); got != "ACTIVATED\n" {
117+
t.Errorf("output = %q, want ACTIVATED", got)
118+
}
119+
}
120+
121+
func TestRemoveActive(t *testing.T) {
122+
if runtime.GOOS == "windows" {
123+
t.Skip("Unix symlink semantics")
124+
}
125+
dir := t.TempDir()
126+
binDir := filepath.Join(dir, "bin")
127+
target := filepath.Join(dir, "php")
128+
writeExecutable(t, target, "#!/bin/sh\n")
129+
130+
if _, _, err := Activate(binDir, "php", target); err != nil {
131+
t.Fatal(err)
132+
}
133+
if err := RemoveActive(binDir, "php"); err != nil {
134+
t.Fatalf("RemoveActive: %v", err)
135+
}
136+
if _, _, ok := ReadActive(binDir, "php"); ok {
137+
t.Error("active entry should be gone after RemoveActive")
138+
}
139+
// idempotent
140+
if err := RemoveActive(binDir, "php"); err != nil {
141+
t.Errorf("RemoveActive on missing entry should be nil, got %v", err)
142+
}
143+
}
144+
145+
func TestReadActiveMissing(t *testing.T) {
146+
dir := t.TempDir()
147+
if _, _, ok := ReadActive(dir, "php"); ok {
148+
t.Error("ReadActive on empty dir should be ok=false")
149+
}
150+
}
151+
152+
// --- pure shim tests (run on all platforms) ---
153+
154+
func TestWindowsShimContent(t *testing.T) {
155+
c := windowsShimContent(`C:\opt\php-debugger\8.3\bin\php.exe`)
156+
if !strings.Contains(c, `"C:\opt\php-debugger\8.3\bin\php.exe" %*`) {
157+
t.Errorf("shim content missing quoted target + args:\n%s", c)
158+
}
159+
if !strings.Contains(c, "@echo off") {
160+
t.Errorf("shim content missing @echo off:\n%s", c)
161+
}
162+
}
163+
164+
func TestParseShimTargetRoundTrip(t *testing.T) {
165+
target := `C:\Program Files\php-debugger\8.3\bin\php.exe`
166+
got, err := parseShimTarget(windowsShimContent(target))
167+
if err != nil {
168+
t.Fatalf("parseShimTarget: %v", err)
169+
}
170+
if got != target {
171+
t.Errorf("parsed target = %q, want %q", got, target)
172+
}
173+
}
174+
175+
func TestWriteAndReadShim(t *testing.T) {
176+
shimPath := filepath.Join(t.TempDir(), "php.cmd")
177+
target := `C:\opt\php-debugger\8.4-zts\bin\php.exe`
178+
if err := writeShim(shimPath, target); err != nil {
179+
t.Fatalf("writeShim: %v", err)
180+
}
181+
got, err := readShimTarget(shimPath)
182+
if err != nil {
183+
t.Fatalf("readShimTarget: %v", err)
184+
}
185+
if got != target {
186+
t.Errorf("read target = %q, want %q", got, target)
187+
}
188+
}
189+
190+
func TestParseShimTargetError(t *testing.T) {
191+
if _, err := parseShimTarget("@echo off\r\nnot a quoted target\r\n"); err == nil {
192+
t.Error("expected error parsing shim with no quoted target")
193+
}
194+
}

0 commit comments

Comments
 (0)