diff options
Diffstat (limited to 'internal/resource/link')
| -rw-r--r-- | internal/resource/link/hardlink.go | 59 | ||||
| -rw-r--r-- | internal/resource/link/link.go | 118 | ||||
| -rw-r--r-- | internal/resource/link/link_test.go | 189 | ||||
| -rw-r--r-- | internal/resource/link/symlink.go | 56 |
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 +} |
