summaryrefslogtreecommitdiff
path: root/internal/resource/link
diff options
context:
space:
mode:
Diffstat (limited to 'internal/resource/link')
-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
4 files changed, 422 insertions, 0 deletions
diff --git a/internal/resource/link/hardlink.go b/internal/resource/link/hardlink.go
new file mode 100644
index 0000000..7307344
--- /dev/null
+++ b/internal/resource/link/hardlink.go
@@ -0,0 +1,59 @@
+package link
+
+import (
+ "fmt"
+ "log"
+ "os"
+ "syscall"
+)
+
+// ensureHardlink ensures l.path is a hard link to l.target.
+//
+// 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 ensureHardlink(l *Link) error {
+ log.Printf("processing hardlink: %s -> %s", l.path, l.target)
+
+ if l.target == "" {
+ return fmt.Errorf("hardlink %s has no target", l.path)
+ }
+
+ targetInfo, err := os.Stat(l.target)
+ if err != nil {
+ return fmt.Errorf("failed to stat hardlink target %s: %w", l.target, err)
+ }
+
+ if info, err := os.Lstat(l.path); err == nil {
+ if sameInode(info, targetInfo) {
+ log.Printf("hardlink %s already links to %s", l.path, l.target)
+ return nil
+ }
+ old := l.path + ".old"
+ log.Printf("%s already exists, renaming to %s", l.path, old)
+ if err := os.Rename(l.path, old); err != nil {
+ return fmt.Errorf("failed to move existing %s aside: %w", l.path, err)
+ }
+ } else if !os.IsNotExist(err) {
+ return fmt.Errorf("failed to stat %s: %w", l.path, err)
+ }
+
+ if err := os.Link(l.target, l.path); err != nil {
+ return fmt.Errorf("failed to create hardlink %s -> %s: %w", l.path, l.target, err)
+ }
+
+ log.Printf("created hardlink %s -> %s", l.path, l.target)
+ 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/resource/link/link.go b/internal/resource/link/link.go
new file mode 100644
index 0000000..0a6b8e6
--- /dev/null
+++ b/internal/resource/link/link.go
@@ -0,0 +1,118 @@
+package link
+
+import (
+ "fmt"
+ "log"
+ "os"
+
+ "codeberg.org/snonux/gonf/internal/resource"
+)
+
+type kind int
+
+const (
+ unsetKind kind = iota
+ symlinkKind
+ hardlinkKind
+)
+
+type Link struct {
+ path string
+ target string
+ kind kind
+ absent bool
+}
+
+type Option func(*Link)
+
+func IsSymlink(target string) Option {
+ return func(l *Link) {
+ l.kind = symlinkKind
+ l.target = target
+ }
+}
+
+func IsHardlink(target string) Option {
+ return func(l *Link) {
+ l.kind = hardlinkKind
+ l.target = target
+ }
+}
+
+func IsAbsent() Option {
+ return func(l *Link) {
+ l.absent = true
+ }
+}
+
+func build(path string, opts ...Option) *Link {
+ l := &Link{path: path}
+ for _, opt := range opts {
+ opt(l)
+ }
+ return l
+}
+
+// apply performs the idempotent OS work for l without registering a
+// resource. Kind-vs-absent validation happens here (not in build) to match
+// the pre-split behavior where a resource was registered before its target
+// was validated.
+func (l *Link) apply() error {
+ switch {
+ case l.absent:
+ return ensureAbsent(l.path)
+ case l.kind == symlinkKind:
+ return ensureSymlink(l)
+ case l.kind == hardlinkKind:
+ return ensureHardlink(l)
+ default:
+ return fmt.Errorf("link %s: must specify IsSymlink, IsHardlink, or IsAbsent", l.path)
+ }
+}
+
+// resourceType returns the registry type name for l. Absent links with no
+// kind specified register generically as "Link", since removal doesn't
+// depend on knowing the prior kind.
+func (l *Link) resourceType() string {
+ switch l.kind {
+ case symlinkKind:
+ return "Symlink"
+ case hardlinkKind:
+ return "Hardlink"
+ default:
+ return "Link"
+ }
+}
+
+// Ensure builds and applies the link resource described by opts, without
+// registering it. Used by other resource packages (e.g. dir) to recreate an
+// individual symlink without it becoming its own top-level resource.
+func Ensure(path string, opts ...Option) error {
+ return build(path, opts...).apply()
+}
+
+func Have(path string, opts ...Option) resource.Resource {
+ l := build(path, opts...)
+ res := resource.Register(l.resourceType(), l.path)
+
+ if err := l.apply(); err != nil {
+ log.Fatalf("failed to apply link resource %s: %v", path, err)
+ }
+
+ return res
+}
+
+func ensureAbsent(path string) error {
+ log.Printf("ensuring link 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
+}
diff --git a/internal/resource/link/link_test.go b/internal/resource/link/link_test.go
new file mode 100644
index 0000000..8da674b
--- /dev/null
+++ b/internal/resource/link/link_test.go
@@ -0,0 +1,189 @@
+package link
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+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).
+ l := &Link{path: link, kind: symlinkKind, target: target}
+ if err := ensureSymlink(l); 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 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).
+ l := &Link{path: link, kind: hardlinkKind, target: target}
+ if err := ensureHardlink(l); 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 := Ensure(link, IsHardlink(filepath.Join(dir, "nope"))); err == nil {
+ t.Error("expected error when hardlink target does not exist")
+ }
+}
+
+func TestHaveAbsentSymlink(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 := os.Symlink(target, link); err != nil {
+ t.Fatal(err)
+ }
+
+ Have(link, IsAbsent())
+ if _, err := os.Lstat(link); !os.IsNotExist(err) {
+ t.Errorf("expected %s to be removed", link)
+ }
+
+ // Idempotent: removing a missing link is not an error (direct call to
+ // avoid duplicate registration in the one-per-process registry).
+ if err := ensureAbsent(link); err != nil {
+ t.Fatalf("absent on missing link: %v", err)
+ }
+}
+
+func TestHaveAbsentWithoutKindRegistersGenericLink(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "whatever")
+
+ l := build(path, IsAbsent())
+ if got := l.resourceType(); got != "Link" {
+ t.Errorf("expected generic resource type %q, got %q", "Link", got)
+ }
+}
+
+func TestBuildRequiresKindOrAbsent(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "nope")
+ if err := Ensure(path); err == nil {
+ t.Error("expected error when neither IsSymlink, IsHardlink, nor IsAbsent is set")
+ }
+}
diff --git a/internal/resource/link/symlink.go b/internal/resource/link/symlink.go
new file mode 100644
index 0000000..c7cb361
--- /dev/null
+++ b/internal/resource/link/symlink.go
@@ -0,0 +1,56 @@
+package link
+
+import (
+ "fmt"
+ "log"
+ "os"
+)
+
+// ensureSymlink ensures l.path is a symlink pointing at l.target.
+//
+// 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 ensureSymlink(l *Link) error {
+ log.Printf("processing symlink: %s -> %s", l.path, l.target)
+
+ if l.target == "" {
+ return fmt.Errorf("symlink %s has no target", l.path)
+ }
+
+ info, err := os.Lstat(l.path)
+ switch {
+ case err == nil && info.Mode()&os.ModeSymlink != 0:
+ current, err := os.Readlink(l.path)
+ if err != nil {
+ return fmt.Errorf("failed to read symlink %s: %w", l.path, err)
+ }
+ if current == l.target {
+ log.Printf("symlink %s already points at %s", l.path, l.target)
+ return nil
+ }
+ log.Printf("repointing symlink %s from %s to %s", l.path, current, l.target)
+ if err := os.Remove(l.path); err != nil {
+ return fmt.Errorf("failed to remove stale symlink %s: %w", l.path, err)
+ }
+
+ case err == nil:
+ old := l.path + ".old"
+ log.Printf("%s is a real file/dir, renaming to %s", l.path, old)
+ if err := os.Rename(l.path, old); err != nil {
+ return fmt.Errorf("failed to move existing %s aside: %w", l.path, err)
+ }
+
+ case !os.IsNotExist(err):
+ return fmt.Errorf("failed to stat %s: %w", l.path, err)
+ }
+
+ if err := os.Symlink(l.target, l.path); err != nil {
+ return fmt.Errorf("failed to create symlink %s -> %s: %w", l.path, l.target, err)
+ }
+
+ log.Printf("created symlink %s -> %s", l.path, l.target)
+ return nil
+}