diff --git a/internal/platform/writefile.go b/internal/platform/writefile.go new file mode 100644 index 0000000..a1c66ac --- /dev/null +++ b/internal/platform/writefile.go @@ -0,0 +1,25 @@ +package platform + +import ( + "os" + "path/filepath" +) + +// WriteUserFile writes data to path atomically (tmp + rename) with the +// given mode, creating any missing parent directories (mode 0755). +// Intended for user-owned destinations under $HOME — no sudo. +func WriteUserFile(path string, data []byte, mode os.FileMode) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + tmp := path + ".tmp" + if err := os.WriteFile(tmp, data, mode); err != nil { + os.Remove(tmp) + return err + } + if err := os.Rename(tmp, path); err != nil { + os.Remove(tmp) + return err + } + return nil +} diff --git a/internal/platform/writefile_test.go b/internal/platform/writefile_test.go new file mode 100644 index 0000000..c7f2736 --- /dev/null +++ b/internal/platform/writefile_test.go @@ -0,0 +1,54 @@ +package platform + +import ( + "os" + "path/filepath" + "testing" +) + +func TestWriteUserFileCreatesParent(t *testing.T) { + dir := t.TempDir() + dest := filepath.Join(dir, "a", "b", "c.txt") + if err := WriteUserFile(dest, []byte("hi"), 0o644); err != nil { + t.Fatal(err) + } + got, _ := os.ReadFile(dest) + if string(got) != "hi" { + t.Fatalf("content %q", got) + } + info, _ := os.Stat(dest) + if info.Mode().Perm() != 0o644 { + t.Fatalf("mode %v", info.Mode().Perm()) + } +} + +func TestWriteUserFileOverwritesAtomically(t *testing.T) { + dir := t.TempDir() + dest := filepath.Join(dir, "x") + if err := WriteUserFile(dest, []byte("one"), 0o644); err != nil { + t.Fatal(err) + } + if err := WriteUserFile(dest, []byte("two"), 0o644); err != nil { + t.Fatal(err) + } + got, _ := os.ReadFile(dest) + if string(got) != "two" { + t.Fatalf("got %q want two", got) + } + // No .tmp sibling should be left behind. + if _, err := os.Stat(dest + ".tmp"); !os.IsNotExist(err) { + t.Fatalf(".tmp left behind: %v", err) + } +} + +func TestWriteUserFileExecMode(t *testing.T) { + dir := t.TempDir() + dest := filepath.Join(dir, "bin") + if err := WriteUserFile(dest, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + info, _ := os.Stat(dest) + if info.Mode().Perm() != 0o755 { + t.Fatalf("mode %v want 0755", info.Mode().Perm()) + } +}