package bind import ( "encoding/hex" "os" "os/exec" "path/filepath" "strings" "testing" ) // probeOut frames body the way zoneStateProbe does, so the parser is exercised // on realistic input. func probeOut(body ...string) string { body = append(body, probeOrphanOpen, probeOrphanShut) return strings.Join(append(append([]string{probeBegin}, body...), probeEnd, ""), "\n") } func journalHeader(magic string, begin, end uint32) []byte { b := make([]byte, 32) copy(b, magic) put := func(off int, v uint32) { b[off] = byte(v >> 24) b[off+1] = byte(v >> 16) b[off+2] = byte(v >> 8) b[off+3] = byte(v) } put(16, begin) put(24, end) return b } func TestParseZoneSerial(t *testing.T) { bindDump := `$ORIGIN . $TTL 3600 ; 1 hour k8s.syd1.au.unkin.net IN SOA ns1.k8s.syd1.au.unkin.net. hostmaster.k8s.syd1.au.unkin.net. ( 16 ; serial 300 ; refresh (5 minutes) 60 ; retry (1 minute) 1209600 ; expire (2 weeks) 60 ; minimum (1 minute) ) ` cases := []struct { name string content string want int64 ok bool }{ {"bind dump", bindDump, 16, true}, {"seed", renderSeedZone("example.com", "10.0.0.1", nil, 42), 42, true}, {"single line", "@ IN SOA ns1.example.com. hostmaster.example.com. 7 300 60 1209600 60\n", 7, true}, {"glued paren", "@ IN SOA ns. host. (9 300 60 1209600 60)\n", 9, true}, {"no soa", "$TTL 3600\nwww IN A 192.0.2.1\n", 0, false}, {"truncated", "@ IN SOA ns.\n", 0, false}, {"non numeric serial", "@ IN SOA ns. host. ( abc 300 )\n", 0, false}, } for _, c := range cases { got, ok := ParseZoneSerial(c.content) if got != c.want || ok != c.ok { t.Errorf("%s: ParseZoneSerial = (%d,%v) want (%d,%v)", c.name, got, ok, c.want, c.ok) } } } func TestParseJournalHeader(t *testing.T) { begin, end, ok := parseJournalHeader(journalHeader(";BIND LOG V9.2\n", 10, 16)) if !ok || begin != 10 || end != 16 { t.Errorf("V9.2 header = (%d,%d,%v) want (10,16,true)", begin, end, ok) } if _, _, ok := parseJournalHeader(journalHeader("not a journal\n", 10, 16)); ok { t.Error("bad magic should not parse") } if _, _, ok := parseJournalHeader([]byte(";BIND LOG V9.2\n")); ok { t.Error("truncated header should not parse") } } func TestParseZoneDiskState(t *testing.T) { out := probeOut( "zonefile=1", probeHeadOpen, "@ IN SOA ns. host. ( 5 300 60 1209600 60 )", probeHeadShut, "journal=1", "jnl="+hex.EncodeToString(journalHeader(";BIND LOG V9.2\n", 3, 8)), ) st, ok := parseZoneDiskState(out) if !ok { t.Fatalf("well-formed probe rejected: %q", out) } if !st.ZoneFile || !st.ZoneSerialOK || st.ZoneSerial != 5 { t.Errorf("zone file state wrong: %+v", st) } if !st.Journal || !st.JournalOK || st.JournalBegin != 3 || st.JournalEnd != 8 { t.Errorf("journal state wrong: %+v", st) } empty, ok := parseZoneDiskState(probeOut("zonefile=0", "journal=0")) if !ok || empty.ZoneFile || empty.Journal { t.Errorf("empty state wrong: %+v (ok=%v)", empty, ok) } } // A probe that returns nothing useful must not be read as "fresh install": the // shell exits 0 after its last printf and stderr is dropped on success, so a // missing tool or a truncated stream is otherwise invisible. func TestParseZoneDiskStateRejectsDegradedProbe(t *testing.T) { live := probeHeadOpen + "\n@ IN SOA ns. host. ( 5 300 60 1209600 60 )\n" + probeHeadShut cases := map[string]string{ "empty output": "", "whitespace only": "\n\n", "no framing": "zonefile=0\njournal=0\n", "no terminator": probeBegin + "\nzonefile=0\njournal=0\n", "cut before zone file": probeBegin + "\n", "cut mid head": probeBegin + "\nzonefile=1\n" + probeHeadOpen + "\n@ IN SOA ns. host. ( 5", "cut after head": probeBegin + "\nzonefile=1\n" + live + "\n", "no zone declaration": probeOut("journal=0"), "no journal branch": probeOut("zonefile=1", live), "journal without hex": probeOut("zonefile=0", "journal=1"), "duplicate zone decl": probeOut("zonefile=0", "zonefile=1", "journal=0"), "header without file": probeOut("zonefile=0", "journal=0", "jnl=00"), "no orphan block": strings.Join( []string{probeBegin, "zonefile=0", "journal=0", probeEnd, ""}, "\n"), "orphan block unterminated": strings.Join( []string{probeBegin, "zonefile=0", "journal=0", probeOrphanOpen, probeEnd, ""}, "\n"), "duplicate orphan block": strings.Join( []string{probeBegin, "zonefile=0", "journal=0", probeOrphanOpen, probeOrphanShut, probeOrphanOpen, probeOrphanShut, probeEnd, ""}, "\n"), } for name, out := range cases { // The zero state is a legitimate fresh install, so rejection has to // happen here: ZoneDiskState turns it into an error and nothing plans. if st, ok := parseZoneDiskState(out); ok { t.Errorf("%s: degraded probe accepted as %+v", name, st) } } } // applyPlan models what the pod filesystem looks like after the plan runs, so // a second PlanSeed can be checked for idempotence. func applyPlan(st ZoneDiskState, p SeedPlan) ZoneDiskState { if p.QuarantineJournal { st.Journal, st.JournalBegin, st.JournalEnd, st.JournalOK = false, 0, 0, false } if p.QuarantineZoneFile { st.ZoneFile, st.ZoneSerial, st.ZoneSerialOK = false, 0, false } if p.WriteSeed { st.ZoneFile, st.ZoneSerial, st.ZoneSerialOK = true, p.Serial, true } return st } func TestPlanSeedFreshInstall(t *testing.T) { p := PlanSeed(ZoneDiskState{}) if !p.WriteSeed || p.Serial != 1 { t.Fatalf("fresh install should seed at serial 1, got %+v", p) } if p.QuarantineZoneFile || p.QuarantineJournal { t.Errorf("fresh install should quarantine nothing, got %+v", p) } } func TestPlanSeedOrphanJournal(t *testing.T) { st := ZoneDiskState{Journal: true, JournalBegin: 10, JournalEnd: 16, JournalOK: true} p := PlanSeed(st) if !p.WriteSeed { t.Fatalf("orphan journal should still seed, got %+v", p) } if !p.QuarantineJournal || p.QuarantineZoneFile { t.Errorf("only the journal should be quarantined, got %+v", p) } if p.Serial <= 16 { t.Errorf("seed serial %d must exceed the journal end serial 16", p.Serial) } if p.QuarantineSuffix != ".orphaned-16" { t.Errorf("quarantine suffix should be deterministic, got %q", p.QuarantineSuffix) } } func TestPlanSeedFileRegressedBehindJournal(t *testing.T) { // The production failure: a skeleton at serial 1 left next to a journal at // serial 16, which BIND refuses to replay ("out of range"). st := ZoneDiskState{ ZoneFile: true, ZoneSerial: 1, ZoneSerialOK: true, Journal: true, JournalBegin: 10, JournalEnd: 16, JournalOK: true, } p := PlanSeed(st) if !p.WriteSeed || p.Serial <= 16 { t.Fatalf("reseed must land above the journal end serial, got %+v", p) } if !p.QuarantineJournal || !p.QuarantineZoneFile { t.Errorf("the unloadable pair should both be moved aside, got %+v", p) } after := applyPlan(st, p) if after.Journal { t.Error("journal should be gone after quarantine") } if next := PlanSeed(after); next != (SeedPlan{}) { t.Errorf("second reconcile should be a no-op, got %+v", next) } if after.ZoneSerial != p.Serial { t.Errorf("second reconcile changed the serial: %d want %d", after.ZoneSerial, p.Serial) } } func TestPlanSeedLeavesHealthyZoneAlone(t *testing.T) { cases := []struct { name string st ZoneDiskState }{ {"file and covering journal", ZoneDiskState{ ZoneFile: true, ZoneSerial: 16, ZoneSerialOK: true, Journal: true, JournalBegin: 10, JournalEnd: 20, JournalOK: true, }}, {"file at journal end", ZoneDiskState{ ZoneFile: true, ZoneSerial: 20, ZoneSerialOK: true, Journal: true, JournalBegin: 10, JournalEnd: 20, JournalOK: true, }}, {"file without journal", ZoneDiskState{ZoneFile: true, ZoneSerial: 16, ZoneSerialOK: true}}, {"unreadable journal header", ZoneDiskState{ ZoneFile: true, ZoneSerial: 16, ZoneSerialOK: true, Journal: true, }}, {"unparsable zone file", ZoneDiskState{ZoneFile: true}}, } for _, c := range cases { if p := PlanSeed(c.st); p != (SeedPlan{}) { t.Errorf("%s: live data must not be touched, got %+v", c.name, p) } } } func TestPlanSeedStaleJournalBehindFile(t *testing.T) { st := ZoneDiskState{ ZoneFile: true, ZoneSerial: 30, ZoneSerialOK: true, Journal: true, JournalBegin: 10, JournalEnd: 16, JournalOK: true, } p := PlanSeed(st) if p.WriteSeed || p.QuarantineZoneFile { t.Fatalf("a file ahead of its journal is live data, got %+v", p) } if !p.QuarantineJournal { t.Errorf("the unreplayable journal should be moved aside, got %+v", p) } if next := PlanSeed(applyPlan(st, p)); next != (SeedPlan{}) { t.Errorf("second reconcile should be a no-op, got %+v", next) } } func TestPlanSeedFreshInstallIdempotent(t *testing.T) { st := ZoneDiskState{} p := PlanSeed(st) after := applyPlan(st, p) if next := PlanSeed(after); next != (SeedPlan{}) { t.Fatalf("second reconcile of a fresh zone should be a no-op, got %+v", next) } if after.ZoneSerial != 1 { t.Errorf("serial reset on second reconcile: %d", after.ZoneSerial) } } func TestPlanSeedSerialWrap(t *testing.T) { st := ZoneDiskState{Journal: true, JournalBegin: 1 << 31, JournalEnd: 1<<32 - 1, JournalOK: true} if h, known := highestSerial(st); !known || h != 1<<32-1 { t.Fatalf("highestSerial = (%d,%v) want (%d,true): the wrap branch is not being reached", h, known, int64(1)<<32-1) } p := PlanSeed(st) if p.Serial != 1 { t.Fatalf("serial after wrap = %d want 1", p.Serial) } if !serialLT(st.JournalEnd, p.Serial) { t.Errorf("wrapped serial %d must still sort after journal end %d", p.Serial, st.JournalEnd) } } // Serials above 2^31 must not be flattened to 1: RFC 1982 comparison against a // zero placeholder reads them as older, and secondaries reject the regression. func TestPlanSeedHighSerialJournal(t *testing.T) { st := ZoneDiskState{Journal: true, JournalBegin: 1<<31 - 10, JournalEnd: 1 << 31, JournalOK: true} p := PlanSeed(st) if !p.WriteSeed { t.Fatalf("orphan journal should still seed, got %+v", p) } if p.Serial != 1<<31+1 { t.Errorf("seed serial = %d want %d", p.Serial, int64(1)<<31+1) } if !serialLT(st.JournalEnd, p.Serial) { t.Errorf("seed serial %d must sort after journal end %d", p.Serial, st.JournalEnd) } if p.QuarantineSuffix != ".orphaned-2147483648" { t.Errorf("quarantine suffix = %q", p.QuarantineSuffix) } } // An orphan journal whose header will not parse (no od, EACCES, short read) // hides how far the zone had advanced, so quarantining it and reseeding at 1 // would regress live data. func TestPlanSeedBlocksOnUnreadableOrphanJournal(t *testing.T) { p := PlanSeed(ZoneDiskState{Journal: true}) if p.Blocked == "" { t.Fatalf("an unreadable orphan journal must block, got %+v", p) } if p.WriteSeed || p.QuarantineJournal || p.QuarantineZoneFile { t.Errorf("a blocked plan must touch nothing, got %+v", p) } } func TestSeedZoneRoundTripsThroughParser(t *testing.T) { content := renderSeedZone("200.18.198.in-addr.arpa", "198.18.200.8", nil, 17) got, ok := ParseZoneSerial(content) if !ok || got != 17 { t.Fatalf("seed zone serial = (%d,%v) want (17,true)", got, ok) } } func TestQuarantinePathsAreSuffixed(t *testing.T) { path := ZoneFilePath("example.com") if JournalPath(path) != path+".jnl" { t.Fatalf("journal path = %q", JournalPath(path)) } cmd := moveAside(JournalPath(path), ".orphaned-16") if !strings.Contains(cmd, "'"+path+".jnl.orphaned-16'") { t.Errorf("quarantine command should rename, not delete: %s", cmd) } if strings.Contains(cmd, "rm ") { t.Errorf("quarantine must never delete: %s", cmd) } } // A repeat incident computes the same suffix, so the rename must not overwrite // the copy preserved by the previous one. func TestMoveAsidePreservesEarlierQuarantine(t *testing.T) { sh, err := exec.LookPath("sh") if err != nil { t.Skipf("no POSIX shell: %v", err) } dir := t.TempDir() path := filepath.Join(dir, "db.example.com") for _, content := range []string{"first", "second"} { if err := os.WriteFile(path, []byte(content), 0o600); err != nil { t.Fatal(err) } out, err := exec.Command(sh, "-c", moveAside(path, ".orphaned-16")).CombinedOutput() if err != nil { t.Fatalf("moveAside(%s): %v (%s)", content, err, out) } } if _, err := os.Stat(path); err == nil { t.Error("the quarantined file should have been renamed away") } entries, err := os.ReadDir(dir) if err != nil { t.Fatal(err) } found := map[string]bool{} for _, e := range entries { b, err := os.ReadFile(filepath.Join(dir, e.Name())) if err != nil { t.Fatal(err) } found[string(b)] = true } for _, want := range []string{"first", "second"} { if !found[want] { t.Errorf("quarantine destroyed %q: %v", want, found) } } } func TestParseJournalHeaderRejectsPaddingGarbage(t *testing.T) { h := journalHeader(";BIND LOG V9\n", 10, 16) h[15] = 'x' if _, _, ok := parseJournalHeader(h); ok { t.Error("format field must match all 16 bytes") } } func TestShellQuoteEscapesQuotes(t *testing.T) { sh, err := exec.LookPath("sh") if err != nil { t.Skipf("no POSIX shell: %v", err) } evil := `a'; touch pwned; echo '` out, err := exec.Command(sh, "-c", "printf %s "+shellQuote(evil)).Output() if err != nil { t.Fatal(err) } if string(out) != evil { t.Errorf("shellQuote round trip = %q want %q", out, evil) } } // The probe is shell, so run it and check the parser agrees with what is // actually on disk; a syntax slip or a missing field would otherwise only // surface as a seed over live data. func TestZoneStateProbeRoundTrip(t *testing.T) { sh, err := exec.LookPath("sh") if err != nil { t.Skipf("no POSIX shell: %v", err) } for _, tool := range []string{"head", "od", "tr"} { if _, err := exec.LookPath(tool); err != nil { t.Skipf("probe needs %s: %v", tool, err) } } cases := []struct { name string zone string jnl []byte orphans []string want ZoneDiskState }{ {name: "fresh install"}, { name: "zone file only", zone: renderSeedZone("example.com", "10.0.0.1", nil, 42), want: ZoneDiskState{ZoneFile: true, ZoneSerial: 42, ZoneSerialOK: true}, }, { name: "zone file and journal", zone: renderSeedZone("example.com", "10.0.0.1", nil, 12), jnl: journalHeader(";BIND LOG V9.2\n", 10, 16), want: ZoneDiskState{ ZoneFile: true, ZoneSerial: 12, ZoneSerialOK: true, Journal: true, JournalBegin: 10, JournalEnd: 16, JournalOK: true, }, }, { name: "orphan journal", jnl: journalHeader(";BIND LOG V9.2\n", 10, 16), want: ZoneDiskState{Journal: true, JournalBegin: 10, JournalEnd: 16, JournalOK: true}, }, { name: "journal with unreadable header", jnl: []byte("garbage"), want: ZoneDiskState{Journal: true}, }, { name: "quarantine evidence only", orphans: []string{".orphaned-16", ".jnl.orphaned-16"}, want: ZoneDiskState{OrphanSerial: 16, OrphanSerialOK: true}, }, { name: "repeat quarantine keeps the highest serial", orphans: []string{".orphaned-16", ".orphaned-30", ".orphaned-30.1"}, want: ZoneDiskState{OrphanSerial: 30, OrphanSerialOK: true}, }, { name: "live zone beside old quarantine evidence", zone: renderSeedZone("example.com", "10.0.0.1", nil, 42), orphans: []string{".orphaned-16"}, want: ZoneDiskState{ ZoneFile: true, ZoneSerial: 42, ZoneSerialOK: true, OrphanSerial: 16, OrphanSerialOK: true, }, }, } for _, c := range cases { path := filepath.Join(t.TempDir(), "db.example.com") if c.zone != "" { if err := os.WriteFile(path, []byte(c.zone), 0o600); err != nil { t.Fatal(err) } } if c.jnl != nil { if err := os.WriteFile(JournalPath(path), c.jnl, 0o600); err != nil { t.Fatal(err) } } for _, suffix := range c.orphans { if err := os.WriteFile(path+suffix, []byte("preserved"), 0o600); err != nil { t.Fatal(err) } } out, err := exec.Command(sh, "-c", zoneStateProbe(path)).Output() if err != nil { t.Fatalf("%s: probe failed: %v", c.name, err) } got, ok := parseZoneDiskState(string(out)) if !ok { t.Errorf("%s: probe output rejected: %q", c.name, out) continue } if got != c.want { t.Errorf("%s: state = %+v want %+v (out %q)", c.name, got, c.want, out) } } } // applyPlanInterrupted models the plan being cut off between the quarantine // renames and the new zone file landing: the PVC holds no zone data at all, and // the .orphaned- siblings are the only record of how far it had got. func applyPlanInterrupted(st ZoneDiskState, p SeedPlan) ZoneDiskState { h, known := highestSerial(st) if p.QuarantineJournal { st.Journal, st.JournalBegin, st.JournalEnd, st.JournalOK = false, 0, 0, false } if p.QuarantineZoneFile { st.ZoneFile, st.ZoneSerial, st.ZoneSerialOK = false, 0, false } if known && (p.QuarantineJournal || p.QuarantineZoneFile) { st.OrphanSerial, st.OrphanSerialOK = h, true } return st } // A reconcile that quarantined and then failed to write leaves a directory that // looks fresh. Seeding it at 1 loads cleanly but every secondary holding the // old serial refuses the transfer, so the zone goes permanently stale. func TestPlanSeedInterruptedTransitionDoesNotRegressSerial(t *testing.T) { st := ZoneDiskState{ ZoneFile: true, ZoneSerial: 1, ZoneSerialOK: true, Journal: true, JournalBegin: 10, JournalEnd: 16, JournalOK: true, } first := PlanSeed(st) if !first.WriteSeed { t.Fatalf("a file behind its journal should be reseeded, got %+v", first) } after := applyPlanInterrupted(st, first) if after.ZoneFile || after.Journal { t.Fatalf("the interrupted state should hold no zone data, got %+v", after) } retry := PlanSeed(after) if !retry.WriteSeed { t.Fatalf("a zone with nothing on disk must still be seeded, got %+v", retry) } if !serialLT(16, retry.Serial) { t.Errorf("reseed at %d regressed below the quarantined serial 16", retry.Serial) } if retry.Serial != first.Serial { t.Errorf("retry seeded at %d, the interrupted attempt planned %d", retry.Serial, first.Serial) } if retry.QuarantineZoneFile || retry.QuarantineJournal { t.Errorf("there is nothing left to quarantine, got %+v", retry) } if next := PlanSeed(applyPlan(after, retry)); next != (SeedPlan{}) { t.Errorf("third reconcile should be a no-op, got %+v", next) } } // Quarantine evidence is a floor, never a trigger: it must not disturb a zone // that is healthy now, and it must not unblock an unjudgeable journal. func TestPlanSeedOrphanEvidenceDoesNotDisturbLiveData(t *testing.T) { healthy := ZoneDiskState{ ZoneFile: true, ZoneSerial: 20, ZoneSerialOK: true, Journal: true, JournalBegin: 10, JournalEnd: 20, JournalOK: true, OrphanSerial: 99, OrphanSerialOK: true, } if p := PlanSeed(healthy); p != (SeedPlan{}) { t.Errorf("a healthy zone must not be touched, got %+v", p) } blocked := ZoneDiskState{Journal: true, OrphanSerial: 99, OrphanSerialOK: true} if p := PlanSeed(blocked); p.Blocked == "" { t.Errorf("an unreadable orphan journal must still block, got %+v", p) } } func TestParseOrphanSerial(t *testing.T) { base := ZoneFilePath("example.com") cases := []struct { name string want int64 ok bool }{ {base + ".orphaned-16", 16, true}, {JournalPath(base) + ".orphaned-16", 16, true}, {base + ".orphaned-16.3", 16, true}, {base + ".orphaned-4294967295", 4294967295, true}, {base + ".orphaned-", 0, false}, {base + ".orphaned-abc", 0, false}, {base + ".orphaned-4294967296", 0, false}, {base, 0, false}, {base + ".jnl", 0, false}, } for _, c := range cases { got, ok := parseOrphanSerial(c.name) if got != c.want || ok != c.ok { t.Errorf("parseOrphanSerial(%q) = (%d,%v) want (%d,%v)", c.name, got, ok, c.want, c.ok) } } if _, ok := orphanSerial(nil); ok { t.Error("no siblings means no recorded serial, not serial 0") } // Serial 0 is legitimate and must not read as "nothing found". if h, ok := orphanSerial([]string{base + ".orphaned-0"}); !ok || h != 0 { t.Errorf("orphanSerial = (%d,%v) want (0,true)", h, ok) } }