package model import ( "slices" "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 { if t == TXT { // byte-exact — case and whitespace are significant (DKIM/SPF/DMARC); // only the presentation quoting/chunking is stripped. return TXTPayload(content) } c := strings.Join(strings.Fields(content), " ") // collapse whitespace switch t { 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) } slices.Sort(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 } return slices.Equal(r.NormalizedValues(), o.NormalizedValues()) } // TXTPayload returns the logical content of a TXT value. A value written as // one or more quoted strings ("a" "b") is the concatenation of their // contents; an unquoted value is returned as is. This makes a template value // without quotes comparable with the quoted form providers store. func TXTPayload(v string) string { t := strings.TrimSpace(v) if !strings.HasPrefix(t, `"`) { return v } var b strings.Builder inQ := false for i := 0; i < len(t); i++ { c := t[i] switch { case inQ && c == '\\' && i+1 < len(t): i++ b.WriteByte(t[i]) case c == '"': inQ = !inQ case inQ: b.WriteByte(c) } } return b.String() } // QuoteTXT renders a TXT value in the quoted form providers require: payload // split into <=255-byte strings, each quoted. Already-quoted values pass // through unchanged. func QuoteTXT(v string) string { if strings.HasPrefix(strings.TrimSpace(v), `"`) { return v } const max = 255 esc := strings.NewReplacer(`\`, `\\`, `"`, `\"`) var parts []string for len(v) > max { parts = append(parts, `"`+esc.Replace(v[:max])+`"`) v = v[max:] } parts = append(parts, `"`+esc.Replace(v)+`"`) return strings.Join(parts, " ") }