diff --git a/README.md b/README.md index 8a7e98e..0a13ebc 100644 --- a/README.md +++ b/README.md @@ -650,6 +650,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 c212724..ab0f8f0 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 save syncs the state file to disk before renaming it into place, so a crash cannot leave it empty and stop the next start (closes #264). - 2026-10-13: a `PORT` that cannot be bound stops startup with an error naming 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..3475476 100644 --- a/internal/state/state_test.go +++ b/internal/state/state_test.go @@ -695,6 +695,71 @@ 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, +// and the first check can then save an entry in each; a section loaded +// as nil makes that panic. +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.Fatalf("loaded %#v, want %#v", got, want) + } + + s.SetDomainState("example.com", &state.DomainState{}) + s.SetHostnameState(testHostname, &state.HostnameState{}) + s.SetPortState(testIP+":443", &state.PortState{}) + s.SetCertificateState( + testIP+":443:"+testHostname, &state.CertificateState{}, + ) + + snap := s.GetSnapshot() + if len(snap.Domains) != 1 || len(snap.Hostnames) != 1 || + len(snap.Ports) != 1 || len(snap.Certificates) != 1 { + t.Errorf("saved one entry in each section, got %#v", snap) + } + }) + } +} + // TestLoadCorruptFile verifies that a corrupt state file returns an error. func TestLoadCorruptFile(t *testing.T) { t.Parallel()