summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--TODO.md43
-rw-r--r--examples/examples.go40
-rw-r--r--internal/file/directory.go57
-rw-r--r--internal/file/file.go305
-rw-r--r--internal/file/file_test.go417
-rw-r--r--internal/file/hardlink.go59
-rw-r--r--internal/file/regular_file.go26
-rw-r--r--internal/file/symlink.go56
-rw-r--r--internal/resource/dir/dir.go224
-rw-r--r--internal/resource/dir/dir_test.go319
-rw-r--r--internal/resource/dir/source.go126
-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
-rw-r--r--internal/resource/link/hardlink.go59
-rw-r--r--internal/resource/link/link.go118
-rw-r--r--internal/resource/link/link_test.go189
-rw-r--r--internal/resource/link/symlink.go56
18 files changed, 1699 insertions, 955 deletions
diff --git a/TODO.md b/TODO.md
index 6f42ecf..153bf71 100644
--- a/TODO.md
+++ b/TODO.md
@@ -7,39 +7,19 @@ Perl [Rex](https://www.rexify.org/) `Rexfile` used to install
## 1. File resource — missing capabilities
-The Rexfile uses `file` for far more than "write these bytes". gonf's
-`file.Have` currently only manages content + checksum idempotency. Missing:
-
-- **`ensure => 'absent'`.** Remove a file if present. Used by `prune_dir`.
-- **`ensure => 'directory'`.** See section 2.
+DONE!
## 2. Directory resource
-Rexfile creates directories with a mode all over the place
-(`~/.config/*`, `~/scripts`, `~/QuickEdit`, `~/.config/systemd/user`, agent
-tool dirs). gonf has no directory concept. Need:
-
-- `Have`-style directory resource: create if missing, enforce mode,
- idempotent, register in the resource registry.
+DONE! `internal/resource/dir` provides a `Have`-style directory resource:
+create if missing, enforce mode, idempotent, registers in the resource
+registry. It also supports installing a source tree (`WithSource`), pruning
+stale destination entries (`WithPrune`), and a file mode independent of the
+directory's own mode (`WithFileMode`).
## 3. Symlink resource
-The Rexfile does a lot of symlink management, none of which gonf supports:
-
-- fish `conf.d` → `~/.config/fish/conf.d` (with rename-to-`.old` fallback).
-- gitsyncer config dir symlink.
-- Agent tool dirs: `~/.cursor`, `~/.claude`, `~/.agents`, `~/.opencode`,
- `~/.pi`, `~/.amp`, `~/.codex` each get `commands`/`skills`/`prompts`
- symlinks into `~/Notes/Prompts/...`.
-- `~/QuickEdit/*` symlinks to many source dirs.
-
-Needs a symlink resource that:
-
-- Creates a symlink to a target.
-- Is idempotent: leaves it alone if it already points at the right place.
-- Repoints if it points elsewhere.
-- Refuses (or has an explicit policy) to clobber a real file/dir; supports the
- Rexfile's rename-existing-dir-to-`.old` behavior where needed.
+DONE!
## 4. Glob / multi-file installs
@@ -50,10 +30,7 @@ sway, waybar, scripts, systemd units, calendar, pipewire).
## 5. Prune / reconcile stale files
-`prune_dir` removes regular files in a destination whose basename is not in the
-source glob (used for `~/scripts`), while leaving dotfiles and subdirectories
-untouched. gonf needs a prune/reconcile operation so removed source files also
-disappear from the destination.
+DONE!
## 6. Package resource (multi-OS)
@@ -135,3 +112,7 @@ just calls a hardcoded `examples.Run()`. Need:
5. Package resource with per-OS backends (section 6).
6. Tasks + CLI (section 11), then git-config / line-in-file / polish
(sections 9, 10, 12).
+
+## More ideas:
+
+* Have file.Absent instead or as an alias for file.Have(path, IsAbsent()) or so
diff --git a/examples/examples.go b/examples/examples.go
index 8fe0f0d..3fe2d50 100644
--- a/examples/examples.go
+++ b/examples/examples.go
@@ -1,7 +1,11 @@
package examples
import (
- "codeberg.org/snonux/gonf/internal/file"
+ "os"
+
+ "codeberg.org/snonux/gonf/internal/resource/dir"
+ "codeberg.org/snonux/gonf/internal/resource/file"
+ "codeberg.org/snonux/gonf/internal/resource/link"
)
func Run() error {
@@ -19,16 +23,44 @@ func Run() error {
)
// 4. A directory
- file.Have("/tmp/gonf_dir", file.IsDirectory(), file.WithMode(0o755))
+ dir.Have("/tmp/gonf_dir", dir.WithMode(0o755))
// 5. A symlink
- file.Have("/tmp/gonf_link", file.IsSymlink("/tmp/gonf_hello.txt"))
+ link.Have("/tmp/gonf_link", link.IsSymlink("/tmp/gonf_hello.txt"))
// 6. A hardlink
- file.Have("/tmp/gonf_hardlink", file.IsHardlink("/tmp/gonf_hello.txt"))
+ link.Have("/tmp/gonf_hardlink", link.IsHardlink("/tmp/gonf_hello.txt"))
// 7. Ensuring something is absent
file.Have("/tmp/gonf_old.txt", file.IsAbsent())
+ // 8. A directory tree copied from source, reconciled, with a distinct
+ // file mode from the directory's own mode
+ dir.Have(
+ "/tmp/gonf_dir_from_source",
+ dir.WithSource("assets/testfiles"),
+ dir.WithPrune(),
+ dir.WithFileMode(0o644),
+ )
+
+ // 9. Recursively removing a directory tree. Each resource path can only
+ // be declared once per run, so this pre-populates its own scratch tree
+ // (rather than reusing #8's path) to give WithPrune's recursive removal
+ // something real to demonstrate.
+ _ = os.MkdirAll("/tmp/gonf_stale_dir/nested", 0o755)
+ dir.Have("/tmp/gonf_stale_dir", dir.IsAbsent(), dir.WithPrune())
+
+ // 10. Non-recursively removing an empty directory
+ _ = os.Mkdir("/tmp/gonf_stale_empty_dir", 0o755)
+ dir.Have("/tmp/gonf_stale_empty_dir", dir.IsAbsent())
+
+ // 11. Ensuring a symlink is absent
+ _ = os.Symlink("/tmp/gonf_hello.txt", "/tmp/gonf_stale_link")
+ link.Have("/tmp/gonf_stale_link", link.IsAbsent())
+
+ // 12. Ensuring a hardlink is absent
+ _ = os.Link("/tmp/gonf_hello.txt", "/tmp/gonf_stale_hardlink")
+ link.Have("/tmp/gonf_stale_hardlink", link.IsAbsent())
+
return nil
}
diff --git a/internal/file/directory.go b/internal/file/directory.go
deleted file mode 100644
index 052af06..0000000
--- a/internal/file/directory.go
+++ /dev/null
@@ -1,57 +0,0 @@
-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
deleted file mode 100644
index 7f86d41..0000000
--- a/internal/file/file.go
+++ /dev/null
@@ -1,305 +0,0 @@
-package file
-
-import (
- "bytes"
- "crypto/sha256"
- "fmt"
- "log"
- "os"
- "os/user"
- "strconv"
- "strings"
- "text/template"
-
- "codeberg.org/snonux/gonf/internal/resource"
-)
-
-type File struct {
- 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)
-
-func WithContent(content string) Option {
- return func(f *File) {
- f.param = content
- f.source = ""
- }
-}
-
-func WithSource(source string) Option {
- return func(f *File) {
- if !strings.HasPrefix(source, "source://") {
- source = "source://" + source
- }
- f.source = source
- f.param = source
- }
-}
-
-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
- 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 IsHardlink(target string) Option {
- return func(f *File) {
- f.hardlink = true
- f.hardlinkTarget = target
- }
-}
-
-func PruneDirectory() Option {
- return func(f *File) {
- f.pruneDirectory = true
- }
-}
-
-func Have(path string, opts ...Option) resource.Resource {
- res, err := have(path, opts...)
- if err != nil {
- log.Fatalf("failed to apply file resource %s: %v", path, err)
- }
-
- return res
-}
-
-func have(path string, opts ...Option) (resource.Resource, error) {
- curr, err := user.Current()
- if err != nil {
- return resource.Resource{}, 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.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() (resource.Resource, error) {
- var res resource.Resource
-
- switch {
- case f.absent:
- res = resource.Register(f.resourceType(), f.path)
- return res, f.haveAbsent()
-
- case f.symlink:
- res = resource.Register("Symlink", f.path)
- return res, f.haveSymlink()
-
- case f.hardlink:
- res = resource.Register("Hardlink", f.path)
- return res, f.haveHardlink()
-
- case f.directory:
- if !f.modeSet {
- f.mode = 0o750
- }
- res = resource.Register("Directory", f.path)
- return res, f.haveDirectory()
-
- default:
- res = resource.Register("File", f.path)
- content, err := f.resolveContent()
- if err != nil {
- return res, fmt.Errorf("failed to resolve content for %s: %w", f.path, err)
- }
- return res, f.haveRegularFile(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) {
- var content []byte
- var err error
-
- if strings.HasPrefix(f.param, "source://") {
- sourcePath := strings.TrimPrefix(f.param, "source://")
- content, err = os.ReadFile(sourcePath)
- if err != nil {
- return nil, fmt.Errorf("failed to read source file %s: %w", sourcePath, err)
- }
- } else {
- content = []byte(f.param)
- }
-
- if strings.HasSuffix(f.path, ".tmpl") || (strings.HasPrefix(f.param, "source://") && strings.HasSuffix(strings.TrimPrefix(f.param, "source://"), ".tmpl")) {
- return f.applyTemplate(content)
- }
-
- return content, nil
-}
-
-func (f *File) applyTemplate(content []byte) ([]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"] = f.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) applyAttributes() error {
- // Apply Mode
- if err := os.Chmod(f.path, f.mode); err != nil {
- return fmt.Errorf("failed to chmod %s to %v: %w", f.path, f.mode, err)
- }
- log.Printf("set mode %v for %s", f.mode, f.path)
-
- // Apply User and Group
- 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(f.path, uid, gid); err != nil {
- return fmt.Errorf("failed to chown %s to %s:%s: %w", f.path, f.user, f.group, err)
- }
- log.Printf("set owner %s:%s for %s", f.user, f.group, f.path)
-
- return nil
-}
-
-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 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/file/file_test.go b/internal/file/file_test.go
deleted file mode 100644
index a6488ce..0000000
--- a/internal/file/file_test.go
+++ /dev/null
@@ -1,417 +0,0 @@
-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)
- }
- // Mask to check only permission bits
- 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)
- }
-
- expectedParam := sourcePath
- if !strings.Contains(string(got), expectedParam) {
- 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")
-
- Have(path, IsDirectory())
-
- 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)
- }
-
- Have(link, IsSymlink(target))
- 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)
- }
-
- Have(link, IsSymlink(newT))
- 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)
- }
-
- Have(link, IsSymlink(target))
-
- 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)
- }
-
- 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).
- 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)
- }
-
- Have(link, IsHardlink(target))
-
- 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)
- }
-
- Have(link, IsHardlink(target))
-
- 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, IsHardlink(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)
- }
-
- Have(target, IsAbsent(), PruneDirectory())
- 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
deleted file mode 100644
index 8913674..0000000
--- a/internal/file/hardlink.go
+++ /dev/null
@@ -1,59 +0,0 @@
-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
deleted file mode 100644
index 7747b9e..0000000
--- a/internal/file/regular_file.go
+++ /dev/null
@@ -1,26 +0,0 @@
-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
deleted file mode 100644
index 8d014c2..0000000
--- a/internal/file/symlink.go
+++ /dev/null
@@ -1,56 +0,0 @@
-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 {
- ret