package main import ( "bufio" "fmt" "os" "strings" ) // ZoneEntry represents a parsed line from the input file. type ZoneEntry struct { Zone string // Normalized FQDN Catalogs []string // Catalog names (bare names from input) Group string // Optional RFC 9432 group property COO string // Optional RFC 9432 change-of-ownership FQDN File string // Source file Line int // Source line number } // CatalogMembers groups zone entries by catalog name. type CatalogMembers map[string][]ZoneEntry func parseInput(path string, cfg *Config) (CatalogMembers, error) { f, err := os.Open(path) if err != nil { return nil, fmt.Errorf("opening input: %w", err) } defer f.Close() var entries []ZoneEntry scanner := bufio.NewScanner(f) lineNum := 0 for scanner.Scan() { lineNum++ line := strings.TrimSpace(scanner.Text()) if line == "" || strings.HasPrefix(line, "#") { continue } entry, err := parseLine(line, path, lineNum) if err != nil { return nil, err } entries = append(entries, entry) } if err := scanner.Err(); err != nil { return nil, fmt.Errorf("reading %s: %w", path, err) } return buildCatalogMembers(entries, cfg) } func parseLine(line, file string, lineNum int) (ZoneEntry, error) { // Split on whitespace first, then handle comma separation within tokens tokens := tokenize(line) if len(tokens) < 2 { return ZoneEntry{}, fmt.Errorf("error: %s:%d: expected zone name followed by at least one catalog name", file, lineNum) } entry := ZoneEntry{ Zone: normalizeFQDN(tokens[0]), File: file, Line: lineNum, } for _, tok := range tokens[1:] { if strings.Contains(tok, "=") { key, value, _ := strings.Cut(tok, "=") switch strings.ToLower(key) { case "group": if value == "" { return ZoneEntry{}, fmt.Errorf("error: %s:%d: empty group value", file, lineNum) } entry.Group = value case "coo": if value == "" { return ZoneEntry{}, fmt.Errorf("error: %s:%d: empty coo value", file, lineNum) } entry.COO = normalizeFQDN(value) default: return ZoneEntry{}, fmt.Errorf("error: %s:%d: unknown property %q", file, lineNum, key) } } else { // Bare name = catalog assignment entry.Catalogs = append(entry.Catalogs, tok) } } if len(entry.Catalogs) == 0 { return ZoneEntry{}, fmt.Errorf("error: %s:%d: no catalog assignment for zone %s", file, lineNum, entry.Zone) } return entry, nil } // tokenize splits a line on whitespace and commas, stripping empty tokens. func tokenize(line string) []string { // Replace commas with spaces, then split on whitespace line = strings.ReplaceAll(line, ",", " ") fields := strings.Fields(line) return fields } func buildCatalogMembers(entries []ZoneEntry, cfg *Config) (CatalogMembers, error) { members := make(CatalogMembers) // Track duplicates: catalog -> zone -> line seen := make(map[string]map[string]int) for _, entry := range entries { for _, catName := range entry.Catalogs { if _, ok := cfg.Catalogs[catName]; !ok { return nil, fmt.Errorf("error: %s:%d: unknown catalog %q", entry.File, entry.Line, catName) } if seen[catName] == nil { seen[catName] = make(map[string]int) } if prevLine, dup := seen[catName][entry.Zone]; dup { return nil, fmt.Errorf("error: %s:%d: zone %s already assigned to catalog %q at line %d", entry.File, entry.Line, entry.Zone, catName, prevLine) } seen[catName][entry.Zone] = entry.Line members[catName] = append(members[catName], entry) } } return members, nil }