From dd91c93bdadfb64b57a0f1e69f372db20066e401 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Fri, 3 Jul 2026 12:22:38 +0700 Subject: [PATCH] =?UTF-8?q?feat(model):=20=D0=BD=D0=B5=D0=B9=D1=82=D1=80?= =?UTF-8?q?=D0=B0=D0=BB=D1=8C=D0=BD=D0=B0=D1=8F=20=D0=BC=D0=BE=D0=B4=D0=B5?= =?UTF-8?q?=D0=BB=D1=8C=20Record=20=D1=81=20=D0=BD=D0=BE=D1=80=D0=BC=D0=B0?= =?UTF-8?q?=D0=BB=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D0=B5=D0=B9=20=D0=B8=20Equ?= =?UTF-8?q?al?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Makefile | 7 +++ go.mod | 3 + internal/model/record.go | 105 ++++++++++++++++++++++++++++++++++ internal/model/record_test.go | 56 ++++++++++++++++++ 4 files changed, 171 insertions(+) create mode 100644 Makefile create mode 100644 go.mod create mode 100644 internal/model/record.go create mode 100644 internal/model/record_test.go diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..0d1fe41 --- /dev/null +++ b/Makefile @@ -0,0 +1,7 @@ +.PHONY: test +test: + go test ./... + +.PHONY: build +build: + go build ./... diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..d340757 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/vasyakrg/dns-autoresolver + +go 1.26.4 diff --git a/internal/model/record.go b/internal/model/record.go new file mode 100644 index 0000000..b419c5f --- /dev/null +++ b/internal/model/record.go @@ -0,0 +1,105 @@ +package model + +import ( + "sort" + "strings" +) + +type RecordType string + +const ( + A RecordType = "A" + AAAA RecordType = "AAAA" + CNAME RecordType = "CNAME" + MX RecordType = "MX" + TXT RecordType = "TXT" + SRV RecordType = "SRV" + NS RecordType = "NS" + SOA RecordType = "SOA" +) + +// Managed reports whether the type participates in diff+apply. +// NS and SOA are read-only. +func (t RecordType) Managed() bool { + switch t { + case A, AAAA, CNAME, MX, TXT, SRV: + return true + default: + return false + } +} + +// Record is the provider-neutral representation of a DNS RRset. +// For MX the value is " "; for SRV it is +// " ". Values is an unordered set. +type Record struct { + Type RecordType + Name string + TTL int + Values []string +} + +// Key uniquely identifies an RRset within a zone. +func (r Record) Key() string { + return string(r.Type) + " " + normalizeName(r.Name) +} + +func normalizeName(name string) string { + n := strings.ToLower(strings.TrimSpace(name)) + if n != "" && !strings.HasSuffix(n, ".") { + n += "." + } + return n +} + +// normalizeValue canonicalizes a single RR value for comparison. +func normalizeValue(t RecordType, content string) string { + c := strings.Join(strings.Fields(content), " ") // collapse whitespace + switch t { + case TXT: + return c // case-sensitive — keep as is + case MX: + parts := strings.SplitN(c, " ", 2) + if len(parts) == 2 { + return parts[0] + " " + normalizeName(parts[1]) + } + return c + case SRV: + f := strings.Fields(c) + if len(f) == 4 { + return f[0] + " " + f[1] + " " + f[2] + " " + normalizeName(f[3]) + } + return c + case CNAME, NS: + return normalizeName(c) + default: // A, AAAA, SOA + return strings.ToLower(c) + } +} + +// NormalizedValues returns sorted, normalized values. +func (r Record) NormalizedValues() []string { + out := make([]string, len(r.Values)) + for i, v := range r.Values { + out[i] = normalizeValue(r.Type, v) + } + sort.Strings(out) + return out +} + +// Equal reports whether two records have the same TTL and value set. +func (r Record) Equal(o Record) bool { + if r.TTL != o.TTL { + return false + } + a, b := r.NormalizedValues(), o.NormalizedValues() + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/internal/model/record_test.go b/internal/model/record_test.go new file mode 100644 index 0000000..5b760c2 --- /dev/null +++ b/internal/model/record_test.go @@ -0,0 +1,56 @@ +package model + +import "testing" + +func TestManaged(t *testing.T) { + managed := []RecordType{A, AAAA, CNAME, MX, TXT, SRV} + for _, rt := range managed { + if !rt.Managed() { + t.Errorf("%s should be managed", rt) + } + } + for _, rt := range []RecordType{NS, SOA} { + if rt.Managed() { + t.Errorf("%s should be read-only", rt) + } + } +} + +func TestKeyNormalizesName(t *testing.T) { + r1 := Record{Type: A, Name: "www.Example.com"} + r2 := Record{Type: A, Name: "www.example.com."} + if r1.Key() != r2.Key() { + t.Fatalf("keys differ: %q vs %q", r1.Key(), r2.Key()) + } + if r1.Key() != "A www.example.com." { + t.Fatalf("unexpected key %q", r1.Key()) + } +} + +func TestEqualMXPriorityAndOrder(t *testing.T) { + a := Record{Type: MX, Name: "example.com.", TTL: 3600, Values: []string{"10 mx1.example.com.", "20 mx2.Example.com."}} + b := Record{Type: MX, Name: "example.com.", TTL: 3600, Values: []string{"20 mx2.example.com.", "10 mx1.example.com."}} + if !a.Equal(b) { + t.Fatal("MX records equal regardless of order and target case") + } + c := Record{Type: MX, Name: "example.com.", TTL: 3600, Values: []string{"30 mx1.example.com."}} + if a.Equal(c) { + t.Fatal("different priority must not be equal") + } +} + +func TestEqualTXTCaseSensitive(t *testing.T) { + a := Record{Type: TXT, Name: "example.com.", TTL: 60, Values: []string{"v=DKIM1; p=AbC"}} + b := Record{Type: TXT, Name: "example.com.", TTL: 60, Values: []string{"v=DKIM1; p=abc"}} + if a.Equal(b) { + t.Fatal("TXT is case-sensitive") + } +} + +func TestEqualTTLMatters(t *testing.T) { + a := Record{Type: A, Name: "example.com.", TTL: 300, Values: []string{"1.2.3.4"}} + b := Record{Type: A, Name: "example.com.", TTL: 600, Values: []string{"1.2.3.4"}} + if a.Equal(b) { + t.Fatal("different TTL must not be equal") + } +}