Skip to content

Commit c4d12c3

Browse files
fix: updater preserves symlinks in tar extraction and repairs CLI symlinks on macOS
Two bugs fixed: 1. extractTarGz skipped all symlinks (tar.TypeSymlink → continue). macOS .app bundles may contain internal relative symlinks that are critical. Now preserves relative symlinks whose targets stay within the extraction root, while still rejecting absolute or escaping symlinks for security. 2. refreshCLICopyBestEffort was a no-op on macOS. After the updater replaces the .app bundle, the CLI symlink created by install.sh (e.g. /usr/local/bin/rawrequest → RawRequest.app/Contents/MacOS/RawRequest) could be left broken. Added a darwin implementation that detects and repairs broken or stale CLI symlinks in common PATH directories. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent a956a63 commit c4d12c3

5 files changed

Lines changed: 352 additions & 4 deletions

File tree

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
//go:build darwin
2+
3+
package main
4+
5+
import (
6+
"fmt"
7+
"os"
8+
"path/filepath"
9+
"strings"
10+
)
11+
12+
// refreshCLICopyBestEffort repairs CLI symlinks that may have broken
13+
// during the app-bundle swap. install.sh creates a symlink such as
14+
// /usr/local/bin/rawrequest -> /Applications/RawRequest.app/Contents/MacOS/RawRequest.
15+
// After the updater renames the old .app and moves the new one into place
16+
// the symlink path is the same, but we verify it actually resolves and
17+
// recreate it if it doesn't.
18+
func refreshCLICopyBestEffort(installPath string) {
19+
if !strings.HasSuffix(strings.ToLower(installPath), ".app") {
20+
return
21+
}
22+
23+
binaryPath := filepath.Join(installPath, "Contents", "MacOS", "RawRequest")
24+
if _, err := os.Stat(binaryPath); err != nil {
25+
return
26+
}
27+
28+
candidates := cliSymlinkCandidates()
29+
for _, linkPath := range candidates {
30+
repairCLISymlink(linkPath, binaryPath)
31+
}
32+
}
33+
34+
// cliSymlinkCandidates returns directories where install.sh may have placed
35+
// a "rawrequest" symlink.
36+
func cliSymlinkCandidates() []string {
37+
paths := []string{
38+
"/usr/local/bin/rawrequest",
39+
"/opt/homebrew/bin/rawrequest",
40+
}
41+
if home, err := os.UserHomeDir(); err == nil {
42+
paths = append(paths, filepath.Join(home, ".local", "bin", "rawrequest"))
43+
}
44+
return paths
45+
}
46+
47+
// repairCLISymlink checks whether linkPath is a symlink whose target is
48+
// missing or points into a backup .app bundle, and recreates it pointing
49+
// to targetPath.
50+
func repairCLISymlink(linkPath, targetPath string) {
51+
fi, err := os.Lstat(linkPath)
52+
if err != nil {
53+
return // doesn't exist
54+
}
55+
if fi.Mode()&os.ModeSymlink == 0 {
56+
return // not a symlink, leave it alone
57+
}
58+
59+
currentTarget, err := os.Readlink(linkPath)
60+
if err != nil {
61+
return
62+
}
63+
64+
// Already correct.
65+
if currentTarget == targetPath {
66+
return
67+
}
68+
69+
// If the symlink resolves and doesn't point at a stale backup, leave it.
70+
if _, err := os.Stat(linkPath); err == nil && !strings.Contains(currentTarget, ".bak-") {
71+
return
72+
}
73+
74+
// Symlink is broken or points at a backup — repair it.
75+
fmt.Printf("Repairing CLI symlink %s -> %s\n", linkPath, targetPath)
76+
if err := os.Remove(linkPath); err != nil {
77+
fmt.Printf("Warning: could not remove old symlink %s: %v\n", linkPath, err)
78+
return
79+
}
80+
if err := os.Symlink(targetPath, linkPath); err != nil {
81+
fmt.Printf("Warning: could not create symlink %s -> %s: %v\n", linkPath, targetPath, err)
82+
}
83+
}
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
//go:build darwin
2+
3+
package main
4+
5+
import (
6+
"os"
7+
"path/filepath"
8+
"testing"
9+
)
10+
11+
func TestRepairCLISymlink_FixesBroken(t *testing.T) {
12+
tmp := t.TempDir()
13+
14+
// Simulate the binary inside a new .app bundle.
15+
binary := filepath.Join(tmp, "RawRequest.app", "Contents", "MacOS", "RawRequest")
16+
if err := os.MkdirAll(filepath.Dir(binary), 0o755); err != nil {
17+
t.Fatal(err)
18+
}
19+
if err := os.WriteFile(binary, []byte("bin"), 0o755); err != nil {
20+
t.Fatal(err)
21+
}
22+
23+
// Create a broken symlink (target doesn't exist).
24+
linkPath := filepath.Join(tmp, "rawrequest")
25+
if err := os.Symlink("/no/such/path/RawRequest", linkPath); err != nil {
26+
t.Fatal(err)
27+
}
28+
29+
repairCLISymlink(linkPath, binary)
30+
31+
got, err := os.Readlink(linkPath)
32+
if err != nil {
33+
t.Fatalf("readlink after repair: %v", err)
34+
}
35+
if got != binary {
36+
t.Fatalf("expected symlink to point to %s, got %s", binary, got)
37+
}
38+
}
39+
40+
func TestRepairCLISymlink_FixesBackup(t *testing.T) {
41+
tmp := t.TempDir()
42+
43+
// Simulate old backup binary that still exists.
44+
oldBinary := filepath.Join(tmp, "RawRequest.app.bak-20260101T000000Z", "Contents", "MacOS", "RawRequest")
45+
if err := os.MkdirAll(filepath.Dir(oldBinary), 0o755); err != nil {
46+
t.Fatal(err)
47+
}
48+
if err := os.WriteFile(oldBinary, []byte("old"), 0o755); err != nil {
49+
t.Fatal(err)
50+
}
51+
52+
// Simulate the new binary.
53+
newBinary := filepath.Join(tmp, "RawRequest.app", "Contents", "MacOS", "RawRequest")
54+
if err := os.MkdirAll(filepath.Dir(newBinary), 0o755); err != nil {
55+
t.Fatal(err)
56+
}
57+
if err := os.WriteFile(newBinary, []byte("new"), 0o755); err != nil {
58+
t.Fatal(err)
59+
}
60+
61+
// Symlink pointing at the old backup (still resolves, but stale).
62+
linkPath := filepath.Join(tmp, "rawrequest")
63+
if err := os.Symlink(oldBinary, linkPath); err != nil {
64+
t.Fatal(err)
65+
}
66+
67+
repairCLISymlink(linkPath, newBinary)
68+
69+
got, err := os.Readlink(linkPath)
70+
if err != nil {
71+
t.Fatalf("readlink after repair: %v", err)
72+
}
73+
if got != newBinary {
74+
t.Fatalf("expected symlink to point to %s, got %s", newBinary, got)
75+
}
76+
}
77+
78+
func TestRepairCLISymlink_LeavesCorrectAlone(t *testing.T) {
79+
tmp := t.TempDir()
80+
81+
binary := filepath.Join(tmp, "RawRequest.app", "Contents", "MacOS", "RawRequest")
82+
if err := os.MkdirAll(filepath.Dir(binary), 0o755); err != nil {
83+
t.Fatal(err)
84+
}
85+
if err := os.WriteFile(binary, []byte("bin"), 0o755); err != nil {
86+
t.Fatal(err)
87+
}
88+
89+
linkPath := filepath.Join(tmp, "rawrequest")
90+
if err := os.Symlink(binary, linkPath); err != nil {
91+
t.Fatal(err)
92+
}
93+
94+
repairCLISymlink(linkPath, binary)
95+
96+
got, err := os.Readlink(linkPath)
97+
if err != nil {
98+
t.Fatalf("readlink: %v", err)
99+
}
100+
if got != binary {
101+
t.Fatalf("expected %s, got %s", binary, got)
102+
}
103+
}
104+
105+
func TestRepairCLISymlink_SkipsNonSymlink(t *testing.T) {
106+
tmp := t.TempDir()
107+
108+
// Regular file, not a symlink.
109+
filePath := filepath.Join(tmp, "rawrequest")
110+
if err := os.WriteFile(filePath, []byte("regular"), 0o755); err != nil {
111+
t.Fatal(err)
112+
}
113+
114+
repairCLISymlink(filePath, "/some/target")
115+
116+
// File should remain a regular file, untouched.
117+
fi, err := os.Lstat(filePath)
118+
if err != nil {
119+
t.Fatal(err)
120+
}
121+
if fi.Mode()&os.ModeSymlink != 0 {
122+
t.Fatal("regular file was turned into a symlink")
123+
}
124+
}
125+
126+
func TestRepairCLISymlink_SkipsNonexistent(t *testing.T) {
127+
// Should not panic or error on missing path.
128+
repairCLISymlink(filepath.Join(t.TempDir(), "nope"), "/some/target")
129+
}
130+
131+
func TestRefreshCLICopyBestEffort_NonAppPath(t *testing.T) {
132+
// Should be a no-op for non-.app paths.
133+
refreshCLICopyBestEffort("/some/plain/directory")
134+
}
Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
1-
//go:build !windows
1+
//go:build !windows && !darwin
22

33
package main
44

5-
// refreshCLICopyBestEffort is a no-op on non-Windows platforms.
6-
// On macOS the CLI uses a symlink that survives app bundle replacement.
5+
// refreshCLICopyBestEffort is a no-op on platforms other than macOS and Windows.
76
func refreshCLICopyBestEffort(_ string) {}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
package main
2+
3+
import (
4+
"archive/tar"
5+
"compress/gzip"
6+
"os"
7+
"path/filepath"
8+
"testing"
9+
)
10+
11+
func createTarGzWithSymlink(t *testing.T, dest string) {
12+
t.Helper()
13+
f, err := os.Create(dest)
14+
if err != nil {
15+
t.Fatal(err)
16+
}
17+
defer f.Close()
18+
gw := gzip.NewWriter(f)
19+
defer gw.Close()
20+
tw := tar.NewWriter(gw)
21+
defer tw.Close()
22+
23+
// Directory
24+
_ = tw.WriteHeader(&tar.Header{
25+
Typeflag: tar.TypeDir,
26+
Name: "App.app/Contents/MacOS/",
27+
Mode: 0o755,
28+
})
29+
30+
// Regular file
31+
body := []byte("binary-content")
32+
_ = tw.WriteHeader(&tar.Header{
33+
Typeflag: tar.TypeReg,
34+
Name: "App.app/Contents/MacOS/App",
35+
Mode: 0o755,
36+
Size: int64(len(body)),
37+
})
38+
_, _ = tw.Write(body)
39+
40+
// Relative symlink inside the bundle (safe)
41+
_ = tw.WriteHeader(&tar.Header{
42+
Typeflag: tar.TypeSymlink,
43+
Name: "App.app/Contents/MacOS/app-cli",
44+
Linkname: "App",
45+
})
46+
47+
// Absolute symlink (should be skipped for security)
48+
_ = tw.WriteHeader(&tar.Header{
49+
Typeflag: tar.TypeSymlink,
50+
Name: "App.app/Contents/MacOS/evil",
51+
Linkname: "/etc/passwd",
52+
})
53+
54+
// Relative symlink escaping root (should be skipped)
55+
_ = tw.WriteHeader(&tar.Header{
56+
Typeflag: tar.TypeSymlink,
57+
Name: "App.app/Contents/MacOS/escape",
58+
Linkname: "../../../../etc/passwd",
59+
})
60+
}
61+
62+
func TestExtractTarGz_PreservesRelativeSymlinks(t *testing.T) {
63+
tmp := t.TempDir()
64+
archive := filepath.Join(tmp, "test.tar.gz")
65+
createTarGzWithSymlink(t, archive)
66+
67+
dest := filepath.Join(tmp, "extracted")
68+
if err := os.MkdirAll(dest, 0o755); err != nil {
69+
t.Fatal(err)
70+
}
71+
if err := extractTarGz(archive, dest); err != nil {
72+
t.Fatalf("extractTarGz: %v", err)
73+
}
74+
75+
// Regular file should exist.
76+
binaryPath := filepath.Join(dest, "App.app", "Contents", "MacOS", "App")
77+
if _, err := os.Stat(binaryPath); err != nil {
78+
t.Fatalf("expected binary to exist: %v", err)
79+
}
80+
81+
// Safe relative symlink should exist and resolve.
82+
symlinkPath := filepath.Join(dest, "App.app", "Contents", "MacOS", "app-cli")
83+
fi, err := os.Lstat(symlinkPath)
84+
if err != nil {
85+
t.Fatalf("expected safe symlink to exist: %v", err)
86+
}
87+
if fi.Mode()&os.ModeSymlink == 0 {
88+
t.Fatal("expected app-cli to be a symlink")
89+
}
90+
target, err := os.Readlink(symlinkPath)
91+
if err != nil {
92+
t.Fatal(err)
93+
}
94+
if target != "App" {
95+
t.Fatalf("expected symlink target 'App', got %q", target)
96+
}
97+
// Verify the symlink resolves to the actual binary.
98+
if _, err := os.Stat(symlinkPath); err != nil {
99+
t.Fatalf("safe symlink should resolve: %v", err)
100+
}
101+
102+
// Absolute symlink should NOT exist (skipped for security).
103+
evilPath := filepath.Join(dest, "App.app", "Contents", "MacOS", "evil")
104+
if _, err := os.Lstat(evilPath); err == nil {
105+
t.Fatal("absolute symlink should have been skipped")
106+
}
107+
108+
// Escaping symlink should NOT exist.
109+
escapePath := filepath.Join(dest, "App.app", "Contents", "MacOS", "escape")
110+
if _, err := os.Lstat(escapePath); err == nil {
111+
t.Fatal("escaping symlink should have been skipped")
112+
}
113+
}

cmd/rawrequest-updater/updater_main.go

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -318,7 +318,26 @@ func extractTarGz(src, dest string) error {
318318
}
319319
out.Close()
320320
case tar.TypeSymlink:
321-
continue
321+
linkTarget := hdr.Linkname
322+
// Reject absolute symlink targets for security.
323+
if filepath.IsAbs(linkTarget) {
324+
continue
325+
}
326+
// Resolve the full path the symlink would point to and
327+
// ensure it stays within the extraction root.
328+
resolved := filepath.Clean(filepath.Join(filepath.Dir(target), linkTarget))
329+
rel, err := filepath.Rel(dest, resolved)
330+
if err != nil || strings.HasPrefix(rel, "..") {
331+
continue
332+
}
333+
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
334+
return err
335+
}
336+
// Remove any existing entry so os.Symlink doesn't fail.
337+
_ = os.Remove(target)
338+
if err := os.Symlink(linkTarget, target); err != nil {
339+
return err
340+
}
322341
default:
323342
continue
324343
}

0 commit comments

Comments
 (0)