summaryrefslogtreecommitdiff
path: root/internal/resource/file
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-07-04 23:47:32 +0300
committerPaul Buetow <paul@buetow.org>2026-07-04 23:47:32 +0300
commit8228ba742f6b23a93e6a00006feba29225ea89c7 (patch)
tree0147bbb82f99a6056f43492b661ede7fea7e8bea /internal/resource/file
parent6a2796f31444b21488ab9d383292841613a1d9c7 (diff)
refactor
Diffstat (limited to 'internal/resource/file')
-rw-r--r--internal/resource/file/checksum.go67
-rw-r--r--internal/resource/file/file.go246
-rw-r--r--internal/resource/file/file_test.go247
3 files changed, 560 insertions, 0 deletions
diff --git a/internal/resource/file/checksum.go b/internal/resource/file/checksum.go
new file mode 100644
index 0000000..0cbf135
--- /dev/null
+++ b/internal/resource/file/checksum.go
@@ -0,0 +1,67 @@
+package file
+
+import (
+ "crypto/sha256"
+ "log"
+ "os"
+)
+
+func getChecksum(path string) [32]byte {
+ var checksum [32]byte
+ data, err := os.ReadFile(path)
+ if err != nil {
+ log.Printf("reading %s: %v (file does not exist or cannot be read)", path, err)
+ return checksum
+ }
+ checksum = sha256.Sum256(data)
+ log.Printf("computed checksum for %s: %x", path, checksum)
+ return checksum
+}
+
+func (f *File) ensureFile(path string, content []byte) error {
+ existingChecksum := getChecksum(path)
+ newChecksum := sha256.Sum256(content)
+ log.Printf("computed checksum for new content: %x", newChecksum)
+
+ tmpPath := path + ".tmp"
+ if err := writeTmpFile(tmpPath, content, f.mode); err != nil {
+ return err
+ }
+
+ if err := updateFromTmp(tmpPath, path, existingChecksum != newChecksum); err != nil {
+ return err
+ }
+
+ return f.applyAttributesTo(path)
+}
+
+func writeTmpFile(tmpPath string, content []byte, mode os.FileMode) error {
+ log.Printf("writing %d bytes to temporary file %s with mode %v", len(content), tmpPath, mode)
+ if err := os.WriteFile(tmpPath, content, mode); err != nil {
+ log.Printf("failed to write temporary file %s: %v", tmpPath, err)
+ return err
+ }
+ log.Printf("successfully wrote temporary file %s", tmpPath)
+ return nil
+}
+
+func updateFromTmp(tmpPath, path string, checksumChanged bool) error {
+ if !checksumChanged {
+ log.Printf("checksums match, removing temporary file %s", tmpPath)
+ if err := os.Remove(tmpPath); err != nil {
+ log.Printf("failed to remove temporary file %s: %v", tmpPath, err)
+ return err
+ }
+ log.Printf("no changes needed for %s", path)
+ return nil
+ }
+
+ log.Printf("checksums differ, renaming %s to %s", tmpPath, path)
+ if err := os.Rename(tmpPath, path); err != nil {
+ log.Printf("failed to rename %s to %s: %v", tmpPath, path, err)
+ os.Remove(tmpPath)
+ return err
+ }
+ log.Printf("successfully updated %s", path)
+ return nil
+}
diff --git a/internal/resource/file/file.go b/internal/resource/file/file.go
new file mode 100644
index 0000000..b3c1e44
--- /dev/null
+++ b/internal/resource/file/file.go
@@ -0,0 +1,246 @@
+package file
+
+import (
+ "bytes"
+ "fmt"
+ "log"
+ "os"
+ "os/user"
+ "strconv"
+ "strings"
+ "text/template"
+
+ "codeberg.org/snonux/gonf/internal/resource"
+)
+
+type File struct {
+ path string
+ content string
+ source string // bare path, no "source://" prefix
+ user string
+ group string
+ mode os.FileMode
+ absent bool
+}
+
+type Option func(*File)
+
+func WithContent(content string) Option {
+ return func(f *File) {
+ f.content = content
+ f.source = ""
+ }
+}
+
+func WithSource(source string) Option {
+ return func(f *File) {
+ f.source = source
+ f.content = ""
+ }
+}
+
+func WithUser(user string) Option {
+ return func(f *File) {
+ f.user = user
+ }
+}
+
+func WithGroup(group string) Option {
+ return func(f *File) {
+ f.group = group
+ }
+}
+
+func WithMode(mode os.FileMode) Option {
+ return func(f *File) {
+ f.mode = mode
+ }
+}
+
+func IsAbsent() Option {
+ return func(f *File) {
+ f.absent = true
+ }
+}
+
+func build(path string, opts ...Option) (*File, error) {
+ curr, err := user.Current()
+ if err != nil {
+ return nil, fmt.Errorf("failed to get current user for default: %w", err)
+ }
+
+ f := &File{
+ path: path,
+ mode: 0o640,
+ user: curr.Username,
+ group: curr.Gid,
+ }
+
+ for _, opt := range opts {
+ opt(f)
+ }
+
+ return f, nil
+}
+
+// apply performs the idempotent OS work for f without registering a
+// resource.
+func (f *File) apply() error {
+ if f.absent {
+ return ensureAbsent(f.targetPath())
+ }
+
+ finalPath, content, err := f.resolve()
+ if err != nil {
+ return fmt.Errorf("failed to resolve content for %s: %w", f.path, err)
+ }
+
+ return f.ensureFile(finalPath, content)
+}
+
+// targetPath returns the actual on-disk path f writes to. It strips a
+// trailing ".tmpl" suffix from the caller-given path whenever templating was
+// triggered by a ".tmpl"-suffixed source, so a source foo.conf.tmpl never
+// leaves a foo.conf.tmpl behind on disk — whether that source is written via
+// a direct Have/Ensure call or delegated to from a directory source-tree
+// copy, since both routes go through this same function.
+func (f *File) targetPath() string {
+ if strings.HasSuffix(f.source, ".tmpl") {
+ return strings.TrimSuffix(f.path, ".tmpl")
+ }
+ return f.path
+}
+
+// shouldRenderTemplate reports whether content should be rendered through
+// text/template: either the destination path or the source path ends in
+// ".tmpl".
+func (f *File) shouldRenderTemplate() bool {
+ return strings.HasSuffix(f.path, ".tmpl") || strings.HasSuffix(f.source, ".tmpl")
+}
+
+// resolve reads f's content (from source or literal content), renders it as
+// a template if applicable, and returns the final on-disk path alongside the
+// resulting bytes. Param is always the bare source path when source-based
+// (never a "source://"-prefixed string), or the literal content when
+// content-based — one definition used by both the single-file path and by
+// dir's per-file delegation.
+func (f *File) resolve() (string, []byte, error) {
+ var content []byte
+ param := f.content
+
+ if f.source != "" {
+ data, err := os.ReadFile(f.source)
+ if err != nil {
+ return "", nil, fmt.Errorf("failed to read source file %s: %w", f.source, err)
+ }
+ content = data
+ param = f.source
+ } else {
+ content = []byte(f.content)
+ }
+
+ if f.shouldRenderTemplate() {
+ rendered, err := f.applyTemplateToContent(content, param)
+ if err != nil {
+ return "", nil, err
+ }
+ content = rendered
+ }
+
+ return f.targetPath(), content, nil
+}
+
+func (f *File) applyTemplateToContent(content []byte, param string) ([]byte, error) {
+ data := make(map[string]string)
+ for _, env := range os.Environ() {
+ pair := strings.SplitN(env, "=", 2)
+ if len(pair) == 2 {
+ data[pair[0]] = pair[1]
+ }
+ }
+ data["Param"] = param
+
+ tmpl, err := template.New("resource").Parse(string(content))
+ if err != nil {
+ return nil, fmt.Errorf("template parse error: %w", err)
+ }
+
+ var buf bytes.Buffer
+ if err := tmpl.Execute(&buf, data); err != nil {
+ return nil, fmt.Errorf("template execute error: %w", err)
+ }
+ return buf.Bytes(), nil
+}
+
+func (f *File) applyAttributesTo(path string) error {
+ if err := os.Chmod(path, f.mode); err != nil {
+ return fmt.Errorf("failed to chmod %s to %v: %w", path, f.mode, err)
+ }
+ log.Printf("set mode %v for %s", f.mode, path)
+
+ uid, gid := -1, -1
+
+ if f.user != "" {
+ u, err := user.Lookup(f.user)
+ if err != nil {
+ return fmt.Errorf("failed to lookup user %s: %w", f.user, err)
+ }
+ uid, _ = strconv.Atoi(u.Uid)
+ }
+
+ if f.group != "" {
+ gidInt, err := strconv.Atoi(f.group)
+ if err != nil {
+ return fmt.Errorf("group must be numeric for now: %s", f.group)
+ }
+ gid = gidInt
+ }
+
+ if err := os.Chown(path, uid, gid); err != nil {
+ return fmt.Errorf("failed to chown %s to %s:%s: %w", path, f.user, f.group, err)
+ }
+ log.Printf("set owner %s:%s for %s", f.user, f.group, path)
+
+ return nil
+}
+
+func ensureAbsent(path string) error {
+ log.Printf("ensuring absent: %s", path)
+
+ if err := os.Remove(path); err != nil {
+ if os.IsNotExist(err) {
+ log.Printf("%s already absent", path)
+ return nil
+ }
+ return fmt.Errorf("failed to remove %s: %w", path, err)
+ }
+
+ log.Printf("removed %s", path)
+ return nil
+}
+
+// Ensure builds and applies the file resource described by opts, without
+// registering it. Used by other resource packages (e.g. dir) to write an
+// individual file without it becoming its own top-level resource.
+func Ensure(path string, opts ...Option) error {
+ f, err := build(path, opts...)
+ if err != nil {
+ return err
+ }
+ return f.apply()
+}
+
+func Have(path string, opts ...Option) resource.Resource {
+ f, err := build(path, opts...)
+ if err != nil {
+ log.Fatalf("failed to apply file resource %s: %v", path, err)
+ }
+
+ res := resource.Register("File", f.targetPath())
+
+ if err := f.apply(); err != nil {
+ log.Fatalf("failed to apply file resource %s: %v", path, err)
+ }
+
+ return res
+}
diff --git a/internal/resource/file/file_test.go b/internal/resource/file/file_test.go
new file mode 100644
index 0000000..662aee7
--- /dev/null
+++ b/internal/resource/file/file_test.go
@@ -0,0 +1,247 @@
+package file
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestGetChecksum(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "test.txt")
+
+ if err := os.WriteFile(path, []byte("hello"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ checksum := getChecksum(path)
+ if checksum == [32]byte{} {
+ t.Error("expected non-zero checksum")
+ }
+
+ zeroChecksum := getChecksum(filepath.Join(dir, "no-such-file"))
+ var expectedZero [32]byte
+ if zeroChecksum != expectedZero {
+ t.Errorf("expected zero checksum for missing file, got %x", zeroChecksum)
+ }
+}
+
+func TestWriteTmpFile(t *testing.T) {
+ dir := t.TempDir()
+ tmpPath := filepath.Join(dir, "test.tmp")
+ content := []byte("temp content")
+
+ if err := writeTmpFile(tmpPath, content, 0o644); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ got, err := os.ReadFile(tmpPath)
+ if err != nil {
+ t.Fatalf("reading tmp file: %v", err)
+ }
+ if string(got) != string(content) {
+ t.Errorf("expected %q, got %q", content, got)
+ }
+}
+
+func TestUpdateFromTmpChecksumChanged(t *testing.T) {
+ dir := t.TempDir()
+ tmpPath := filepath.Join(dir, "test.tmp")
+ path := filepath.Join(dir, "test.txt")
+
+ if err := os.WriteFile(tmpPath, []byte("new content"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ if err := updateFromTmp(tmpPath, path, true); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ if _, err := os.Stat(tmpPath); err == nil {
+ t.Error("tmp file should have been removed")
+ }
+ got, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatalf("reading target file: %v", err)
+ }
+ if string(got) != "new content" {
+ t.Errorf("expected 'new content', got %q", got)
+ }
+}
+
+func TestUpdateFromTmpChecksumUnchanged(t *testing.T) {
+ dir := t.TempDir()
+ tmpPath := filepath.Join(dir, "test.tmp")
+ path := filepath.Join(dir, "test.txt")
+
+ if err := os.WriteFile(tmpPath, []byte("same"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, []byte("same"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ if err := updateFromTmp(tmpPath, path, false); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ if _, err := os.Stat(tmpPath); err == nil {
+ t.Error("tmp file should have been removed")
+ }
+ got, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatalf("reading target file: %v", err)
+ }
+ if string(got) != "same" {
+ t.Errorf("expected 'same', got %q", got)
+ }
+}
+
+func TestHaveStringCreateNewFile(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "new.txt")
+
+ Have(path, WithContent("hello world"))
+
+ got, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatalf("reading file: %v", err)
+ }
+ if string(got) != "hello world" {
+ t.Errorf("expected 'hello world', got %q", got)
+ }
+}
+
+func TestHaveMode(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "mode.txt")
+ mode := os.FileMode(0o600)
+
+ Have(path, WithContent("mode test"), WithMode(mode))
+
+ info, err := os.Stat(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if info.Mode().Perm() != mode {
+ t.Errorf("expected mode %v, got %v", mode, info.Mode().Perm())
+ }
+}
+
+func TestHaveSourceFile(t *testing.T) {
+ dir := t.TempDir()
+ sourcePath := filepath.Join("..", "..", "..", "assets", "testfiles", "test.txt")
+ targetPath := filepath.Join(dir, "target.txt")
+
+ Have(targetPath, WithSource(sourcePath))
+
+ got, err := os.ReadFile(targetPath)
+ if err != nil {
+ t.Fatalf("reading file: %v", err)
+ }
+ expected, _ := os.ReadFile(sourcePath)
+ if string(got) != string(expected) {
+ t.Errorf("expected %q, got %q", string(expected), string(got))
+ }
+}
+
+func TestHaveTemplateFile(t *testing.T) {
+ dir := t.TempDir()
+ sourcePath := filepath.Join("..", "..", "..", "assets", "testfiles", "test.tmpl")
+ targetPath := filepath.Join(dir, "target.conf")
+
+ Have(targetPath, WithSource(sourcePath))
+
+ got, err := os.ReadFile(targetPath)
+ if err != nil {
+ t.Fatalf("reading file: %v", err)
+ }
+
+ expected := "Hello, " + sourcePath + "!\nWelcome to " + os.Getenv("USER") + ".\n"
+ if string(got) != expected {
+ t.Errorf("expected %q, got %q", expected, string(got))
+ }
+}
+
+func TestHaveAbsent(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "gone.txt")
+ if err := os.WriteFile(path, []byte("bye"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ Have(path, IsAbsent())
+ if _, err := os.Stat(path); !os.IsNotExist(err) {
+ t.Errorf("expected %s to be removed", path)
+ }
+
+ // Idempotent: removing a missing file is not an error (direct call to
+ // avoid duplicate registration in the one-per-process registry).
+ if err := ensureAbsent(path); err != nil {
+ t.Fatalf("absent on missing file: %v", err)
+ }
+}
+
+func TestResolveStripsTmplSuffixWhenSourceHasTmplSuffix(t *testing.T) {
+ dir := t.TempDir()
+ sourcePath := filepath.Join(dir, "foo.conf.tmpl")
+ if err := os.WriteFile(sourcePath, []byte("hello {{.Param}}"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ // Mirrors what dir's copySourceTree mechanically passes: a target path
+ // that still carries the source's own ".tmpl" suffix.
+ dstDir := filepath.Join(dir, "dst")
+ if err := os.Mkdir(dstDir, 0o750); err != nil {
+ t.Fatal(err)
+ }
+ targetPath := filepath.Join(dstDir, "foo.conf.tmpl")
+
+ if err := Ensure(targetPath, WithSource(sourcePath)); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ deSuffixed := filepath.Join(filepath.Dir(targetPath), "foo.conf")
+ if _, err := os.Stat(deSuffixed); err != nil {
+ t.Errorf("expected de-suffixed file to exist at %s: %v", deSuffixed, err)
+ }
+ if _, err := os.Stat(targetPath); !os.IsNotExist(err) {
+ t.Errorf("expected %s to NOT exist on disk", targetPath)
+ }
+}
+
+func TestResolveDoesNotStripTmplWhenOnlyContentTriggersTemplate(t *testing.T) {
+ dir := t.TempDir()
+ targetPath := filepath.Join(dir, "foo.conf.tmpl")
+
+ if err := Ensure(targetPath, WithContent("hello {{.Param}}")); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ if _, err := os.Stat(targetPath); err != nil {
+ t.Errorf("expected %s to exist (not stripped), got %v", targetPath, err)
+ }
+}
+
+func TestParamIsBareSourcePathNoPrefix(t *testing.T) {
+ dir := t.TempDir()
+ sourcePath := filepath.Join(dir, "src.tmpl")
+ if err := os.WriteFile(sourcePath, []byte("{{.Param}}"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ targetPath := filepath.Join(dir, "dst.conf")
+
+ if err := Ensure(targetPath, WithSource(sourcePath)); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ got, err := os.ReadFile(targetPath)
+ if err != nil {
+ t.Fatalf("reading file: %v", err)
+ }
+ if string(got) != sourcePath {
+ t.Errorf("expected Param to equal bare source path %q, got %q", sourcePath, string(got))
+ }
+ if strings.Contains(string(got), "source://") {
+ t.Errorf("Param unexpectedly contains the removed \"source://\" prefix: %q", string(got))
+ }
+}