diff --git a/internal/update/update_unix.go b/internal/update/update_unix.go index c7ae759..e54721e 100644 --- a/internal/update/update_unix.go +++ b/internal/update/update_unix.go @@ -112,6 +112,23 @@ func unlockFile(f *os.File) error { // canReplaceFile returns true if this process can replace the file at the // provided location. +// +// On Unix, rename(2) replacement is governed by the containing directory +// rather than the target file mode, so validate replacement by creating a +// temporary file in that directory. func canReplaceFile(path string) bool { - return unix.Access(path, unix.W_OK) == nil + dir := filepath.Dir(path) + + f, err := os.CreateTemp(dir, ".fetch-update-*") + if err != nil { + return false + } + + name := f.Name() + if err := f.Close(); err != nil { + _ = os.Remove(name) + return false + } + + return os.Remove(name) == nil } diff --git a/internal/update/update_unix_test.go b/internal/update/update_unix_test.go index cc7c73f..10d9dd7 100644 --- a/internal/update/update_unix_test.go +++ b/internal/update/update_unix_test.go @@ -66,6 +66,22 @@ func TestUnpackArtifact_PathTraversal(t *testing.T) { } } +func TestCanReplaceFile_ReadOnlyFileWritableDirectory(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "fetch") + + if err := os.WriteFile(path, []byte("binary"), 0755); err != nil { + t.Fatalf("WriteFile: %v", err) + } + if err := os.Chmod(path, 0555); err != nil { + t.Fatalf("Chmod: %v", err) + } + + if !canReplaceFile(path) { + t.Fatalf("canReplaceFile(%q) = false, want true", path) + } +} + func createTarGz(t *testing.T, filename string, content []byte) []byte { t.Helper() var buf bytes.Buffer