From 83b68923d9f03ea00bb5bdba6462044ca49b97ff Mon Sep 17 00:00:00 2001 From: sneak Date: Tue, 13 Oct 2026 16:49:02 +0000 Subject: [PATCH] state: a state file without a section loads it empty (closes #267) Load fills in each of domains, hostnames, ports and certificates that the file leaves out or holds as null with an empty map. Before, such a section loaded as a nil map, and the first check's write into it panicked, so a state file holding {} stopped the watcher at every start. A state test loads {} and a file missing each section in turn, and checks that every section is an empty map. A watcher test loads {}, and a file without the certificates section, runs the first check against live DNS, and checks that it saved domain, hostname, port and certificate entries. Model: opus-5-5 --- README.md | 4 +++ TODO.md | 2 ++ internal/state/state.go | 18 +++++++++++ internal/state/state_test.go | 52 ++++++++++++++++++++++++++++++++ internal/watcher/watcher_test.go | 48 +++++++++++++++++++++++++++++ 5 files changed, 124 insertions(+) diff --git a/README.md b/README.md index 2eb5697..f073613 100644 --- a/README.md +++ b/README.md @@ -662,6 +662,10 @@ A port entry's `hostnames` lists every name that resolves to its address, domains included. A port entry in the older format, with one `hostname` instead of the `hostnames` list, loads as a list of that one name. +A state file that leaves out any of `domains`, `hostnames`, `ports` and +`certificates`, or holds `null` for one, loads with that section empty. A file +holding `{}` loads as empty state. + --- ## Entrypoints diff --git a/TODO.md b/TODO.md index 855cb8f..d856eae 100644 --- a/TODO.md +++ b/TODO.md @@ -19,6 +19,8 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149 # Completed Steps +- 2026-10-13: a state file holding `{}`, or leaving out a section, loads with + that section empty; the first check no longer crashes on it (closes #267). - 2026-10-13: a stop before the first scan completes sends no startup notification; a scan that ran to the end still sends it (closes #271). - 2026-10-13: a nameserver leaving a domain's delegation sends only NS Change; diff --git a/internal/state/state.go b/internal/state/state.go index f183764..744f4e8 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -216,6 +216,24 @@ func (s *State) Load() error { return fmt.Errorf("parsing state file: %w", err) } + // A section the file leaves out, or holds as null, loads empty: the + // setters write into these maps, and writing into a nil map panics. + if snapshot.Domains == nil { + snapshot.Domains = make(map[string]*DomainState) + } + + if snapshot.Hostnames == nil { + snapshot.Hostnames = make(map[string]*HostnameState) + } + + if snapshot.Ports == nil { + snapshot.Ports = make(map[string]*PortState) + } + + if snapshot.Certificates == nil { + snapshot.Certificates = make(map[string]*CertificateState) + } + // A state file saved before each record value was stored once can // hold a hostname's CNAME once for every record type asked for. // Each value is kept once, so the first check does not see a diff --git a/internal/state/state_test.go b/internal/state/state_test.go index d2a660f..9b3d998 100644 --- a/internal/state/state_test.go +++ b/internal/state/state_test.go @@ -695,6 +695,58 @@ func TestLoadMissingFile(t *testing.T) { } } +// TestLoadFileWithoutSections loads a state file holding {}, and files +// that each leave out one section. Every section loads as an empty map. +// TestFirstCheckAfterLoadingFileWithoutSections in the watcher tests +// runs the first check after such a load. +func TestLoadFileWithoutSections(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + file string + }{ + {"no sections", `{}`}, + {"no domains", `{"hostnames": {}, "ports": {}, "certificates": {}}`}, + {"no hostnames", `{"domains": {}, "ports": {}, "certificates": {}}`}, + {"no ports", `{"domains": {}, "hostnames": {}, "certificates": {}}`}, + {"no certificates", `{"domains": {}, "hostnames": {}, "ports": {}}`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + + err := os.WriteFile( + filepath.Join(dir, "state.json"), []byte(tt.file), 0o600, + ) + if err != nil { + t.Fatalf("writing state file: %v", err) + } + + s := state.NewForTestWithDataDir(dir) + + err = s.Load() + if err != nil { + t.Fatalf("Load() error: %v", err) + } + + want := state.Snapshot{ + Domains: map[string]*state.DomainState{}, + Hostnames: map[string]*state.HostnameState{}, + Ports: map[string]*state.PortState{}, + Certificates: map[string]*state.CertificateState{}, + } + + if got := s.GetSnapshot(); !reflect.DeepEqual(got, want) { + t.Errorf("loaded %#v, want %#v", got, want) + } + }) + } +} + // TestLoadCorruptFile verifies that a corrupt state file returns an error. func TestLoadCorruptFile(t *testing.T) { t.Parallel() diff --git a/internal/watcher/watcher_test.go b/internal/watcher/watcher_test.go index 3c3df91..869df60 100644 --- a/internal/watcher/watcher_test.go +++ b/internal/watcher/watcher_test.go @@ -477,6 +477,54 @@ func TestDomainPortAndTLSChecks(t *testing.T) { } } +// TestFirstCheckAfterLoadingFileWithoutSections loads a state file +// holding {}, and one that leaves out the certificates section, and runs +// the first check. It saves domain, hostname, port and certificate +// entries; a section loaded as nil made it panic. +func TestFirstCheckAfterLoadingFileWithoutSections(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + file string + }{ + {"no sections", `{}`}, + {"no certificates", `{"domains": {}, "hostnames": {}, "ports": {}}`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + cfg := defaultTestConfig(t) + cfg.Domains = []string{testSmallDomain} + + _, deps := runChecks(t, cfg, func(deps *testDeps) { + err := os.WriteFile(cfg.StatePath(), []byte(tt.file), 0o600) + if err != nil { + t.Fatalf("writing the state file: %v", err) + } + + err = deps.state.Load() + if err != nil { + t.Fatalf("loading the state file: %v", err) + } + }) + + snap := deps.state.GetSnapshot() + if len(snap.Domains) == 0 || len(snap.Hostnames) == 0 || + len(snap.Ports) == 0 || len(snap.Certificates) == 0 { + t.Errorf( + "saved %d domain, %d hostname, %d port and %d "+ + "certificate entries, want at least one of each", + len(snap.Domains), len(snap.Hostnames), + len(snap.Ports), len(snap.Certificates), + ) + } + }) + } +} + func TestNSChangeDetection(t *testing.T) { t.Parallel()