summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-07-04 22:29:49 +0300
committerPaul Buetow <paul@buetow.org>2026-07-04 22:29:49 +0300
commit49e4dbf6ee07b13790231091ef8f73f025841a19 (patch)
tree1be5171ca564c1f651886a59391caedfee7253b6 /internal
parent2381c8712ebafa8060f0d2feacadd85bef280eb0 (diff)
add more file types
Diffstat (limited to 'internal')
-rw-r--r--internal/file/directory.go57
-rw-r--r--internal/file/file.go122
-rw-r--r--internal/file/file_test.go268
-rw-r--r--internal/file/hardlink.go59
-rw-r--r--internal/file/regular_file.go26
-rw-r--r--internal/file/symlink.go56
6 files changed, 559 insertions, 29 deletions
diff --git a/internal/file/directory.go b/internal/file/directory.go
new file mode 100644
index 0000000..052af06
--- /dev/null
+++ b/internal/file/directory.go
@@ -0,0 +1,57 @@
+package file
+
+import (
+ "fmt"
+ "log"
+ "os"
+)
+
+// haveDirectory ensures f.path exists as a directory with the desired mode and
+// ownership. It is idempotent: an existing directory only has its attributes
+// re-enforced, and an existing non-directory is an error.
+func (f *File) haveDirectory() error {
+ log.Printf("processing directory: %s", f.path)
+
+ info, err := os.Lstat(f.path)
+ switch {
+ case err == nil:
+ if !info.IsDir() {
+ return fmt.Errorf("%s exists and is not a directory", f.path)
+ }
+ log.Printf("directory %s already exists", f.path)
+
+ case os.IsNotExist(err):
+ log.Printf("creating directory %s with mode %v", f.path, f.mode)
+ if err := os.MkdirAll(f.path, f.mode); err != nil {
+ return fmt.Errorf("failed to create directory %s: %w", f.path, err)
+ }
+
+ default:
+ return fmt.Errorf("failed to stat %s: %w", f.path, err)
+ }
+
+ return f.applyAttributes()
+}
+
+// haveAbsent removes f.path if it exists. It is idempotent: a missing path is
+// not an error. By default non-empty directories are not removed; combine with
+// PruneDirectory() to remove a directory and its contents recursively.
+func (f *File) haveAbsent() error {
+ log.Printf("ensuring absent: %s", f.path)
+
+ remove := os.Remove
+ if f.pruneDirectory {
+ remove = os.RemoveAll
+ }
+
+ if err := remove(f.path); err != nil {
+ if os.IsNotExist(err) {
+ log.Printf("%s already absent", f.path)
+ return nil
+ }
+ return fmt.Errorf("failed to remove %s: %w", f.path, err)
+ }
+
+ log.Printf("removed %s", f.path)
+ return nil
+}
diff --git a/internal/file/file.go b/internal/file/file.go
index cf46f19..e6547c3 100644
--- a/internal/file/file.go
+++ b/internal/file/file.go
@@ -15,12 +15,23 @@ import (
)
type File struct {
- path string
- param string
- source string
- user string
- group string
- mode os.FileMode
+ path string
+ param string
+ source string
+ user string
+ group string
+ mode os.FileMode
+ modeSet bool
+ absent bool
+
+ symlink bool
+ symlinkTarget string
+
+ hardlink bool
+ hardlinkTarget string
+
+ directory bool
+ pruneDirectory bool // with Absent(): remove directory recursively
}
type Option func(*File)
@@ -57,6 +68,39 @@ func WithGroup(group string) Option {
func WithMode(mode os.FileMode) Option {
return func(f *File) {
f.mode = mode
+ f.modeSet = true
+ }
+}
+
+func IsAbsent() Option {
+ return func(f *File) {
+ f.absent = true
+ }
+}
+
+func IsDirectory() Option {
+ return func(f *File) {
+ f.directory = true
+ }
+}
+
+func IsSymlink(target string) Option {
+ return func(f *File) {
+ f.symlink = true
+ f.symlinkTarget = target
+ }
+}
+
+func Hardlink(target string) Option {
+ return func(f *File) {
+ f.hardlink = true
+ f.hardlinkTarget = target
+ }
+}
+
+func PruneDirectory() Option {
+ return func(f *File) {
+ f.pruneDirectory = true
}
}
@@ -80,15 +124,53 @@ func Have(path string, opts ...Option) error {
return f.Apply()
}
+// Apply dispatches to the concrete resource implementation based on the
+// options that were set. Each kind lives in its own file:
+// regular_file.go, directory.go and symlink.go.
func (f *File) Apply() error {
- _ = resource.Register("File", f.path)
+ switch {
+ case f.absent:
+ _ = resource.Register(f.resourceType(), f.path)
+ return f.haveAbsent()
+
+ case f.symlink:
+ _ = resource.Register("Symlink", f.path)
+ return f.haveSymlink()
+
+ case f.hardlink:
+ _ = resource.Register("Hardlink", f.path)
+ return f.haveHardlink()
+
+ case f.directory:
+ if !f.modeSet {
+ f.mode = 0o750
+ }
+ _ = resource.Register("Directory", f.path)
+ return f.haveDirectory()
- content, err := f.resolveContent()
- if err != nil {
- log.Fatalf("failed to resolve content for %s: %v", f.path, err)
+ default:
+ _ = resource.Register("File", f.path)
+ content, err := f.resolveContent()
+ if err != nil {
+ return fmt.Errorf("failed to resolve content for %s: %w", f.path, err)
+ }
+ return f.haveRegularFile(content)
}
+}
- return f.have(content)
+// resourceType returns the registry type name for this resource, used when the
+// concrete kind matters for registration (e.g. absent works for any kind).
+func (f *File) resourceType() string {
+ switch {
+ case f.symlink:
+ return "Symlink"
+ case f.hardlink:
+ return "Hardlink"
+ case f.directory:
+ return "Directory"
+ default:
+ return "File"
+ }
}
func (f *File) resolveContent() ([]byte, error) {
@@ -134,24 +216,6 @@ func (f *File) applyTemplate(content []byte) ([]byte, error) {
return buf.Bytes(), nil
}
-func (f *File) have(content []byte) error {
- log.Printf("processing file: %s", f.path)
- existingChecksum := getChecksum(f.path)
- newChecksum := sha256.Sum256(content)
- log.Printf("computed checksum for new content: %x", newChecksum)
-
- tmpPath := f.path + ".tmp"
- if err := writeTmpFile(tmpPath, content, f.mode); err != nil {
- return err
- }
-
- if err := updateFromTmp(tmpPath, f.path, existingChecksum != newChecksum); err != nil {
- return err
- }
-
- return f.applyAttributes()
-}
-
func (f *File) applyAttributes() error {
// Apply Mode
if err := os.Chmod(f.path, f.mode); err != nil {
diff --git a/internal/file/file_test.go b/internal/file/file_test.go
index a9397ba..d9e09ac 100644
--- a/internal/file/file_test.go
+++ b/internal/file/file_test.go
@@ -171,3 +171,271 @@ func TestHaveTemplateFile(t *testing.T) {
t.Errorf("expected content to contain Param %q, got %q", expectedParam, string(got))
}
}
+
+func TestHaveDirectoryCreate(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "sub", "nested")
+
+ if err := Have(path, IsDirectory()); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ info, err := os.Stat(path)
+ if err != nil {
+ t.Fatalf("stat: %v", err)
+ }
+ if !info.IsDir() {
+ t.Errorf("expected a directory at %s", path)
+ }
+ if info.Mode().Perm() != 0o750 {
+ t.Errorf("expected default dir mode 0750, got %v", info.Mode().Perm())
+ }
+}
+
+func TestHaveDirectoryIdempotentWithMode(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "d")
+
+ // Call the resource logic directly to exercise idempotency without the
+ // one-per-process resource registry rejecting a duplicate registration.
+ f1 := &File{path: path, mode: 0o755}
+ if err := f1.haveDirectory(); err != nil {
+ t.Fatalf("first apply: %v", err)
+ }
+ f2 := &File{path: path, mode: 0o700}
+ if err := f2.haveDirectory(); err != nil {
+ t.Fatalf("second apply: %v", err)
+ }
+
+ info, err := os.Stat(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if info.Mode().Perm() != 0o700 {
+ t.Errorf("expected mode 0700 enforced, got %v", info.Mode().Perm())
+ }
+}
+
+func TestHaveDirectoryFailsWhenFileExists(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "afile")
+ if err := os.WriteFile(path, []byte("x"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ if err := Have(path, IsDirectory()); err == nil {
+ t.Error("expected error when a regular file is in the way of a directory")
+ }
+}
+
+func TestHaveSymlinkCreateAndIdempotent(t *testing.T) {
+ dir := t.TempDir()
+ target := filepath.Join(dir, "target.txt")
+ link := filepath.Join(dir, "link.txt")
+ if err := os.WriteFile(target, []byte("t"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ if err := Have(link, IsSymlink(target)); err != nil {
+ t.Fatalf("create: %v", err)
+ }
+ got, err := os.Readlink(link)
+ if err != nil {
+ t.Fatalf("readlink: %v", err)
+ }
+ if got != target {
+ t.Errorf("expected link -> %s, got %s", target, got)
+ }
+
+ // Re-applying the same link should be a no-op (tested directly to avoid the
+ // one-per-process resource registry rejecting a duplicate registration).
+ f := &File{path: link, symlink: true, symlinkTarget: target}
+ if err := f.haveSymlink(); err != nil {
+ t.Fatalf("idempotent apply: %v", err)
+ }
+}
+
+func TestHaveSymlinkRepoints(t *testing.T) {
+ dir := t.TempDir()
+ old := filepath.Join(dir, "old.txt")
+ newT := filepath.Join(dir, "new.txt")
+ link := filepath.Join(dir, "link")
+ for _, p := range []string{old, newT} {
+ if err := os.WriteFile(p, []byte("x"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := os.Symlink(old, link); err != nil {
+ t.Fatal(err)
+ }
+
+ if err := Have(link, IsSymlink(newT)); err != nil {
+ t.Fatalf("repoint: %v", err)
+ }
+ got, err := os.Readlink(link)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got != newT {
+ t.Errorf("expected repoint to %s, got %s", newT, got)
+ }
+}
+
+func TestHaveSymlinkMovesRealFileAside(t *testing.T) {
+ dir := t.TempDir()
+ target := filepath.Join(dir, "target.txt")
+ link := filepath.Join(dir, "real")
+ if err := os.WriteFile(target, []byte("t"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(link, []byte("original"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ if err := Have(link, IsSymlink(target)); err != nil {
+ t.Fatalf("symlink over real file: %v", err)
+ }
+
+ got, err := os.Readlink(link)
+ if err != nil {
+ t.Fatalf("expected %s to be a symlink: %v", link, err)
+ }
+ if got != target {
+ t.Errorf("expected link -> %s, got %s", target, got)
+ }
+ if data, err := os.ReadFile(link + ".old"); err != nil || string(data) != "original" {
+ t.Errorf("expected original content preserved in %s.old, got %q err %v", link, string(data), err)
+ }
+}
+
+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)
+ }
+
+ if err := Have(path, IsAbsent()); err != nil {
+ t.Fatalf("absent: %v", err)
+ }
+ 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).
+ f := &File{path: path}
+ if err := f.haveAbsent(); err != nil {
+ t.Fatalf("absent on missing file: %v", err)
+ }
+}
+
+func TestHaveHardlinkCreateAndIdempotent(t *testing.T) {
+ dir := t.TempDir()
+ target := filepath.Join(dir, "target.txt")
+ link := filepath.Join(dir, "link.txt")
+ if err := os.WriteFile(target, []byte("payload"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ if err := Have(link, Hardlink(target)); err != nil {
+ t.Fatalf("create: %v", err)
+ }
+
+ ti, err := os.Stat(target)
+ if err != nil {
+ t.Fatal(err)
+ }
+ li, err := os.Stat(link)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !sameInode(ti, li) {
+ t.Errorf("expected %s and %s to share an inode", link, target)
+ }
+
+ // Re-applying the same link should be a no-op (direct call to avoid the
+ // one-per-process resource registry rejecting a duplicate registration).
+ f := &File{path: link, hardlink: true, hardlinkTarget: target}
+ if err := f.haveHardlink(); err != nil {
+ t.Fatalf("idempotent apply: %v", err)
+ }
+}
+
+func TestHaveHardlinkMovesRealFileAside(t *testing.T) {
+ dir := t.TempDir()
+ target := filepath.Join(dir, "target.txt")
+ link := filepath.Join(dir, "real")
+ if err := os.WriteFile(target, []byte("payload"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(link, []byte("original"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ if err := Have(link, Hardlink(target)); err != nil {
+ t.Fatalf("hardlink over real file: %v", err)
+ }
+
+ ti, err := os.Stat(target)
+ if err != nil {
+ t.Fatal(err)
+ }
+ li, err := os.Stat(link)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !sameInode(ti, li) {
+ t.Errorf("expected %s to be hardlinked to %s", link, target)
+ }
+ if data, err := os.ReadFile(link + ".old"); err != nil || string(data) != "original" {
+ t.Errorf("expected original content preserved in %s.old, got %q err %v", link, string(data), err)
+ }
+}
+
+func TestHaveHardlinkMissingTarget(t *testing.T) {
+ dir := t.TempDir()
+ link := filepath.Join(dir, "link")
+ if err := Have(link, Hardlink(filepath.Join(dir, "nope"))); err == nil {
+ t.Error("expected error when hardlink target does not exist")
+ }
+}
+
+func TestHaveAbsentNonEmptyDirWithoutPruneFails(t *testing.T) {
+ dir := t.TempDir()
+ target := filepath.Join(dir, "d")
+ if err := os.MkdirAll(filepath.Join(target, "sub"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ if err := Have(target, IsAbsent()); err == nil {
+ t.Error("expected error removing a non-empty directory without PruneDirectory()")
+ }
+ if _, err := os.Stat(target); err != nil {
+ t.Errorf("expected %s to still exist, got %v", target, err)
+ }
+}
+
+func TestHaveAbsentPruneDirectoryRecursive(t *testing.T) {
+ dir := t.TempDir()
+ target := filepath.Join(dir, "d")
+ if err := os.MkdirAll(filepath.Join(target, "sub", "deep"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(target, "sub", "f.txt"), []byte("x"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ if err := Have(target, IsAbsent(), PruneDirectory()); err != nil {
+ t.Fatalf("prune remove: %v", err)
+ }
+ if _, err := os.Stat(target); !os.IsNotExist(err) {
+ t.Errorf("expected %s to be removed recursively", target)
+ }
+
+ // Idempotent: removing a missing tree is not an error.
+ f := &File{path: target, absent: true, pruneDirectory: true}
+ if err := f.haveAbsent(); err != nil {
+ t.Fatalf("prune on missing tree: %v", err)
+ }
+}
diff --git a/internal/file/hardlink.go b/internal/file/hardlink.go
new file mode 100644
index 0000000..8913674
--- /dev/null
+++ b/internal/file/hardlink.go
@@ -0,0 +1,59 @@
+package file
+
+import (
+ "fmt"
+ "log"
+ "os"
+ "syscall"
+)
+
+// haveHardlink ensures f.path is a hard link to f.hardlinkTarget.
+//
+// Idempotency and clobber policy:
+// - already the same inode as the target: nothing to do.
+// - a different file/link is in the way: it is renamed to "<path>.old"
+// before the link is created (matching the symlink resource's behavior).
+func (f *File) haveHardlink() error {
+ log.Printf("processing hardlink: %s -> %s", f.path, f.hardlinkTarget)
+
+ if f.hardlinkTarget == "" {
+ return fmt.Errorf("hardlink %s has no target", f.path)
+ }
+
+ targetInfo, err := os.Stat(f.hardlinkTarget)
+ if err != nil {
+ return fmt.Errorf("failed to stat hardlink target %s: %w", f.hardlinkTarget, err)
+ }
+
+ if info, err := os.Lstat(f.path); err == nil {
+ if sameInode(info, targetInfo) {
+ log.Printf("hardlink %s already links to %s", f.path, f.hardlinkTarget)
+ return nil
+ }
+ old := f.path + ".old"
+ log.Printf("%s already exists, renaming to %s", f.path, old)
+ if err := os.Rename(f.path, old); err != nil {
+ return fmt.Errorf("failed to move existing %s aside: %w", f.path, err)
+ }
+ } else if !os.IsNotExist(err) {
+ return fmt.Errorf("failed to stat %s: %w", f.path, err)
+ }
+
+ if err := os.Link(f.hardlinkTarget, f.path); err != nil {
+ return fmt.Errorf("failed to create hardlink %s -> %s: %w", f.path, f.hardlinkTarget, err)
+ }
+
+ log.Printf("created hardlink %s -> %s", f.path, f.hardlinkTarget)
+ return nil
+}
+
+// sameInode reports whether two FileInfos refer to the same underlying inode
+// (same device and inode number), i.e. they are already hard-linked.
+func sameInode(a, b os.FileInfo) bool {
+ as, aok := a.Sys().(*syscall.Stat_t)
+ bs, bok := b.Sys().(*syscall.Stat_t)
+ if !aok || !bok {
+ return false
+ }
+ return as.Dev == bs.Dev && as.Ino == bs.Ino
+}
diff --git a/internal/file/regular_file.go b/internal/file/regular_file.go
new file mode 100644
index 0000000..7747b9e
--- /dev/null
+++ b/internal/file/regular_file.go
@@ -0,0 +1,26 @@
+package file
+
+import (
+ "crypto/sha256"
+ "log"
+)
+
+// haveRegularFile writes content to f.path idempotently (via a checksum-guarded
+// temp file) and enforces mode/ownership.
+func (f *File) haveRegularFile(content []byte) error {
+ log.Printf("processing file: %s", f.path)
+ existingChecksum := getChecksum(f.path)
+ newChecksum := sha256.Sum256(content)
+ log.Printf("computed checksum for new content: %x", newChecksum)
+
+ tmpPath := f.path + ".tmp"
+ if err := writeTmpFile(tmpPath, content, f.mode); err != nil {
+ return err
+ }
+
+ if err := updateFromTmp(tmpPath, f.path, existingChecksum != newChecksum); err != nil {
+ return err
+ }
+
+ return f.applyAttributes()
+}
diff --git a/internal/file/symlink.go b/internal/file/symlink.go
new file mode 100644
index 0000000..8d014c2
--- /dev/null
+++ b/internal/file/symlink.go
@@ -0,0 +1,56 @@
+package file
+
+import (
+ "fmt"
+ "log"
+ "os"
+)
+
+// haveSymlink ensures f.path is a symlink pointing at f.symlinkTarget.
+//
+// Idempotency and clobber policy:
+// - already points at the target: nothing to do.
+// - points elsewhere: the link is removed and recreated.
+// - a real file/dir is in the way: it is renamed to "<path>.old" before the
+// link is created (matching the Rexfile's rename-existing behavior).
+func (f *File) haveSymlink() error {
+ log.Printf("processing symlink: %s -> %s", f.path, f.symlinkTarget)
+
+ if f.symlinkTarget == "" {
+ return fmt.Errorf("symlink %s has no target", f.path)
+ }
+
+ info, err := os.Lstat(f.path)
+ switch {
+ case err == nil && info.Mode()&os.ModeSymlink != 0:
+ current, err := os.Readlink(f.path)
+ if err != nil {
+ return fmt.Errorf("failed to read symlink %s: %w", f.path, err)
+ }
+ if current == f.symlinkTarget {
+ log.Printf("symlink %s already points at %s", f.path, f.symlinkTarget)
+ return nil
+ }
+ log.Printf("repointing symlink %s from %s to %s", f.path, current, f.symlinkTarget)
+ if err := os.Remove(f.path); err != nil {
+ return fmt.Errorf("failed to remove stale symlink %s: %w", f.path, err)
+ }
+
+ case err == nil:
+ old := f.path + ".old"
+ log.Printf("%s is a real file/dir, renaming to %s", f.path, old)
+ if err := os.Rename(f.path, old); err != nil {
+ return fmt.Errorf("failed to move existing %s aside: %w", f.path, err)
+ }
+
+ case !os.IsNotExist(err):
+ return fmt.Errorf("failed to stat %s: %w", f.path, err)
+ }
+
+ if err := os.Symlink(f.symlinkTarget, f.path); err != nil {
+ return fmt.Errorf("failed to create symlink %s -> %s: %w", f.path, f.symlinkTarget, err)
+ }
+
+ log.Printf("created symlink %s -> %s", f.path, f.symlinkTarget)
+ return nil
+}