Skip to content

Commit 1ea7776

Browse files
Fix permission denied error in kat update on macOS (#32)
## Summary - `os.Rename` from a macOS temp dir (`/var/folders/...`) to `/usr/local/bin` fails with \"permission denied\" because it crosses filesystem boundaries and requires elevated privileges - Replace the cross-filesystem rename with `copyFile` (an `io.Copy`-based helper) that writes the new binary as `kat.new` inside the destination directory, followed by an atomic same-directory rename - Add a preflight `os.CreateTemp` check at the start of `DownloadAndReplace` so permission errors surface immediately — before any download — with a clear message: _try running with sudo_ - Clean up the `.new` file if the final rename fails so no leftover file is left behind ## Test plan - [ ] Run `kat update` as a non-root user with `kat` installed in `/usr/local/bin` — should now see a clear `permission denied: ... try running with sudo` message instead of the cryptic rename error - [x] Run `sudo kat update` — should complete successfully - [ ] Run `kat update` as a user who owns the install directory — should complete successfully without sudo
1 parent 47a51da commit 1ea7776

File tree

2 files changed

+49
-9
lines changed

2 files changed

+49
-9
lines changed

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# Kat
22

3+
<a href="https://www.producthunt.com/products/kat-3?embed=true&amp;utm_source=badge-featured&amp;utm_medium=badge&amp;utm_campaign=badge-kat-3" target="_blank" rel="noopener noreferrer"><img alt="Kat - Database migrations for the top 1% | Product Hunt" width="250" height="54" src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=1081944&amp;theme=neutral&amp;t=1771554914075"></a>
4+
35
[![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://github.com/BolajiOlajide/kat/blob/main/LICENSE)
46
[![Go Report Card](https://goreportcard.com/badge/github.com/BolajiOlajide/kat)](https://goreportcard.com/report/github.com/BolajiOlajide/kat)
57
[![Go Reference](https://pkg.go.dev/badge/github.com/BolajiOlajide/kat.svg)](https://pkg.go.dev/github.com/BolajiOlajide/kat)
@@ -103,7 +105,7 @@ kat add create_users_table
103105
# Developer A: Add email feature (Kat determines create_users_table as parent)
104106
kat add add_email_column
105107

106-
# Developer B: Add posts feature (creates parallel branch from users table)
108+
# Developer B: Add posts feature (creates parallel branch from users table)
107109
kat add create_posts_table
108110

109111
# Developer C: Add full-text search (Kat resolves dependencies automatically)

internal/update/update.go

Lines changed: 46 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,20 @@ func CheckForUpdates() (bool, string, string, error) {
103103

104104
// DownloadAndReplace downloads a new binary and replaces the current one
105105
func DownloadAndReplace(downloadURL, execPath string, progressWriter io.Writer) error {
106+
execDir := filepath.Dir(execPath)
107+
execName := filepath.Base(execPath)
108+
109+
// Preflight: verify we can write to the destination directory before downloading.
110+
probe, err := os.CreateTemp(execDir, execName+".probe-*")
111+
if err != nil {
112+
if os.IsPermission(err) {
113+
return errors.Newf("permission denied: cannot write to %s — try running with sudo", execDir)
114+
}
115+
return errors.Wrap(err, "cannot write to destination directory")
116+
}
117+
_ = probe.Close()
118+
_ = os.Remove(probe.Name())
119+
106120
// Create a temporary directory to work in
107121
tempDir, err := os.MkdirTemp("", "kat-update-*")
108122
if err != nil {
@@ -157,21 +171,45 @@ func DownloadAndReplace(downloadURL, execPath string, progressWriter io.Writer)
157171
return errors.Wrap(err, "failed to make binary executable")
158172
}
159173

160-
// On Unix-like systems, we can replace the binary directly
161-
execDir := filepath.Dir(execPath)
162-
execName := filepath.Base(execPath)
163-
164-
// Move the new executable to the same directory as the current one
174+
// Copy the new binary into the destination directory first.
175+
// os.Rename cannot cross filesystem boundaries (e.g. from /var/folders to
176+
// /usr/local/bin on macOS), so we copy into the same directory and then
177+
// do an atomic same-directory rename.
165178
newExecPath := filepath.Join(execDir, execName+".new")
166-
if err := os.Rename(tempBinaryPath, newExecPath); err != nil {
167-
return errors.Wrap(err, "failed to move new executable to destination directory")
179+
if err := copyFile(tempBinaryPath, newExecPath, 0755); err != nil {
180+
if os.IsPermission(err) {
181+
return errors.Newf("permission denied: cannot write to %s — try running with sudo", execDir)
182+
}
183+
return errors.Wrap(err, "failed to copy new executable to destination directory")
168184
}
169185

170-
// Replace the current executable with the new one
186+
// Replace the current executable with the new one (atomic within same directory)
171187
if err := os.Rename(newExecPath, execPath); err != nil {
188+
_ = os.Remove(newExecPath)
189+
if os.IsPermission(err) {
190+
return errors.Newf("permission denied: cannot replace %s — try running with sudo", execPath)
191+
}
172192
return errors.Wrap(err, "failed to replace current executable")
173193
}
174194

175195
fmt.Fprintf(progressWriter, "%sUpdate successfully installed%s\n", output.StyleSuccess, output.StyleReset)
176196
return nil
177197
}
198+
199+
// copyFile copies src to dst with the given permission bits.
200+
func copyFile(src, dst string, mode os.FileMode) error {
201+
srcFile, err := os.Open(src)
202+
if err != nil {
203+
return err
204+
}
205+
defer srcFile.Close()
206+
207+
dstFile, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode)
208+
if err != nil {
209+
return err
210+
}
211+
defer dstFile.Close()
212+
213+
_, err = io.Copy(dstFile, srcFile)
214+
return err
215+
}

0 commit comments

Comments
 (0)