package hosts import ( "fmt" "os" "path/filepath" "strings" "testing" ) func Test_Hosts(t *testing.T) { var h = NewHosts() tmpDir := t.TempDir() err := h.Parse("./test_hosts.txt") if err != nil { t.Error(err) } next := h.firstUid for next != 0 { node, _ := h.GetNode(next) fmt.Printf("Last %d, Next: %d, IP: %s, Hosts: %s, Comment: %s\n", node.LastUID(), node.NextUID(), node.IP(), node.Hosts(), node.Comment()) next = node.NextUID() } data := h.ListHostsByIP("11.22.33.44") if len(data) != 2 { t.Error("Expected 2, got ", len(data)) } else { t.Log(data) } data = h.ListIPsByHost("dns.b612.me") if len(data) < 1 || data[0] != "4.5.6.7" { t.Error("Expected 4.5.6.7, got ", data) } else { t.Log(data) } err = h.RemoveHosts("dns.b612.me") if err != nil { t.Error(err) } data = h.ListIPsByHost("dns.b612.me") if len(data) > 0 { t.Error("Expected 0, got ", len(data)) } else { t.Log(data) } err = h.RemoveHosts("test.dns.set.b612.me") if err != nil { t.Error(err) } data = h.ListIPsByHost("remove.b612.me") if len(data) < 1 || data[0] != "11.22.33.44" { t.Error("Expected 11.22.33.44, got ", data) } else { t.Log(data) } nodes := h.ListByIP("11.22.33.44") if nodes == nil { t.Error("Expected not nil, got ", nodes) } else { t.Log(nodes) } nodes[0].AddHosts("hello.b612.me") err = h.UpdateNode(nodes[0]) if err != nil { t.Error(err) } data = h.ListIPsByHost("hello.b612.me") if len(data) < 1 || data[0] != "11.22.33.44" { t.Error("Not Expected Data", data) } else { t.Log(data) } insertNode := new(HostNode) insertNode.SetIP("11.11.11.11") insertNode.SetHosts("insert.b612.me") insertNode.SetComment("Insert Node") insertNode.SetNextUID(nodes[0].UID()) insertNode.SetLastUID(nodes[0].LastUID()) err = h.InsertNode(insertNode) if err != nil { t.Error(err) } data = h.ListIPsByHost("insert.b612.me") if len(data) < 1 || data[0] != "11.11.11.11" { t.Error("Expected 11.11.11.11 got ", data) } else { t.Log(data) } err = h.SaveAs(filepath.Join(tmpDir, "test_hosts_01.txt")) if err != nil { t.Error(err) } err = h.DeleteNode(insertNode) if err != nil { t.Error(err) } data = h.ListIPsByHost("insert.b612.me") if len(data) > 0 { t.Error("Expected 0 got ", data) } else { t.Log(data) } for i := 0; i < 100; i++ { err = h.RemoveHosts("release-ftpd") if err != nil { t.Error(err) } err = h.AddHosts("2.3.4.9", "release-ftpd") if err != nil { t.Error(err) } } err = h.SetHostIPs("ssh.b612.me", "9.9.9.9") if err != nil { t.Error(err) } data = h.ListIPsByHost("ssh.b612.me") if len(data) == 0 { t.Error("Expected 1 got ", data) } else { t.Log(data) } err = h.SetIPHosts("10.10.10.10", "ssh.b612.me", "ssr.b612.me") if len(data) == 0 { t.Error("Expected 1 got ", data) } err = h.SaveAs(filepath.Join(tmpDir, "test_hosts_02.txt")) if err != nil { t.Error(err) } } func BenchmarkAddHosts(b *testing.B) { var h = NewHosts() err := h.Parse("./test_hosts.txt") if err != nil { b.Error(err) } for i := 0; i < b.N; i++ { err = h.AddHosts("1.3.4.5", "test.b612.me") if err != nil { b.Error(err) } } } func TestParseHandlesEmptyAndNoTrailingNewline(t *testing.T) { t.Run("empty file", func(t *testing.T) { h := NewHosts() path := filepath.Join(t.TempDir(), "hosts.empty") if err := os.WriteFile(path, nil, 0o644); err != nil { t.Fatal(err) } if err := h.Parse(path); err != nil { t.Fatal(err) } if got := h.List(); len(got) != 0 { t.Fatalf("expected empty hosts list, got %d entries", len(got)) } }) t.Run("last line without newline", func(t *testing.T) { h := NewHosts() path := filepath.Join(t.TempDir(), "hosts.nonewline") if err := os.WriteFile(path, []byte("1.2.3.4 example.test"), 0o644); err != nil { t.Fatal(err) } if err := h.Parse(path); err != nil { t.Fatal(err) } if got := h.ListIPsByHost("example.test"); len(got) != 1 || got[0] != "1.2.3.4" { t.Fatalf("expected last line to be parsed, got %v", got) } node, err := h.GetLatestNode() if err != nil { t.Fatal(err) } if node.NextUID() != 0 { t.Fatalf("expected last node next uid 0, got %d", node.NextUID()) } }) } func TestAddHostsAndAddNodeWorkOnEmptyModel(t *testing.T) { t.Run("add hosts", func(t *testing.T) { h := NewHosts() if err := h.AddHosts("1.2.3.4", "example.test"); err != nil { t.Fatal(err) } if got := h.ListFirstIPByHost("example.test"); got != "1.2.3.4" { t.Fatalf("expected inserted host ip, got %q", got) } out, err := h.Build() if err != nil { t.Fatal(err) } if !strings.Contains(string(out), "1.2.3.4 example.test") { t.Fatalf("unexpected build output: %q", out) } }) t.Run("add node", func(t *testing.T) { h := NewHosts() node := &HostNode{} node.SetIP("5.6.7.8") node.SetHosts("node.test") if err := h.AddNode(node); err != nil { t.Fatal(err) } if node.UID() == 0 { t.Fatal("expected node uid to be assigned") } if got := h.ListFirstIPByHost("node.test"); got != "5.6.7.8" { t.Fatalf("expected inserted node ip, got %q", got) } }) } func TestInsertNodeByDataInsertsAndLinksNode(t *testing.T) { h := NewHosts() path := filepath.Join(t.TempDir(), "hosts.insert") if err := os.WriteFile(path, []byte("2.2.2.2 anchor.test\n"), 0o644); err != nil { t.Fatal(err) } if err := h.Parse(path); err != nil { t.Fatal(err) } anchor, err := h.GetFirstNode() if err != nil { t.Fatal(err) } if err := h.InsertNodeByData(anchor, true, "before", "1.1.1.1", "before.test"); err != nil { t.Fatal(err) } if err := h.InsertNodeByData(anchor, false, "after", "3.3.3.3", "after.test"); err != nil { t.Fatal(err) } nodes := h.List() if len(nodes) != 3 { t.Fatalf("expected 3 nodes after insert, got %d", len(nodes)) } if nodes[0].IP() != "1.1.1.1" || nodes[1].IP() != "2.2.2.2" || nodes[2].IP() != "3.3.3.3" { t.Fatalf("unexpected node order: %q, %q, %q", nodes[0].IP(), nodes[1].IP(), nodes[2].IP()) } if got := h.ListFirstIPByHost("before.test"); got != "1.1.1.1" { t.Fatalf("expected before node to be indexed, got %q", got) } if got := h.ListFirstIPByHost("after.test"); got != "3.3.3.3" { t.Fatalf("expected after node to be indexed, got %q", got) } if nodes[0].NextUID() != nodes[1].UID() || nodes[1].LastUID() != nodes[0].UID() { t.Fatalf("before/anchor linkage broken: before.next=%d anchor.uid=%d anchor.last=%d", nodes[0].NextUID(), nodes[1].UID(), nodes[1].LastUID()) } if nodes[1].NextUID() != nodes[2].UID() || nodes[2].LastUID() != nodes[1].UID() { t.Fatalf("anchor/after linkage broken: anchor.next=%d after.uid=%d after.last=%d", nodes[1].NextUID(), nodes[2].UID(), nodes[2].LastUID()) } } func TestInsertNodeByDataRejectsNilAnchor(t *testing.T) { h := NewHosts() path := filepath.Join(t.TempDir(), "hosts.insert.nil") if err := os.WriteFile(path, []byte("2.2.2.2 anchor.test\n"), 0o644); err != nil { t.Fatal(err) } if err := h.Parse(path); err != nil { t.Fatal(err) } if err := h.InsertNodeByData(nil, true, "before", "1.1.1.1", "before.test"); err == nil { t.Fatal("expected nil anchor error") } } func TestSetIPHostsUpdatesReverseIndex(t *testing.T) { h := NewHosts() if err := h.AddHosts("1.2.3.4", "old.test"); err != nil { t.Fatal(err) } if err := h.SetIPHosts("1.2.3.4", "new.test"); err != nil { t.Fatal(err) } if got := h.ListIPsByHost("new.test"); len(got) != 1 || got[0] != "1.2.3.4" { t.Fatalf("expected new reverse index, got %v", got) } if got := h.ListIPsByHost("old.test"); len(got) != 0 { t.Fatalf("expected old reverse index to be removed, got %v", got) } } func TestSetIPHostsDeduplicatesHosts(t *testing.T) { h := NewHosts() if err := h.AddHosts("1.2.3.4", "old.test"); err != nil { t.Fatal(err) } if err := h.SetIPHosts("1.2.3.4", "new.test", "new.test"); err != nil { t.Fatal(err) } if got := h.ListHostsByIP("1.2.3.4"); len(got) != 1 || got[0] != "new.test" { t.Fatalf("expected deduplicated ip mapping, got %v", got) } if got := h.ListIPsByHost("new.test"); len(got) != 1 || got[0] != "1.2.3.4" { t.Fatalf("expected deduplicated reverse index, got %v", got) } } func TestSetIPHostsReplacesMultipleSameIPNodes(t *testing.T) { h := NewHosts() if err := h.AddHosts("1.2.3.4", "first.test"); err != nil { t.Fatal(err) } if err := h.AddHosts("1.2.3.4", "second.test"); err != nil { t.Fatal(err) } if err := h.AddHosts("5.6.7.8", "tail.test"); err != nil { t.Fatal(err) } if err := h.SetIPHosts("1.2.3.4", "new.test"); err != nil { t.Fatal(err) } if got := h.ListHostsByIP("1.2.3.4"); len(got) != 1 || got[0] != "new.test" { t.Fatalf("expected replaced same-ip mappings, got %v", got) } if got := h.ListIPsByHost("first.test"); len(got) != 0 { t.Fatalf("expected first old host to disappear, got %v", got) } if got := h.ListIPsByHost("second.test"); len(got) != 0 { t.Fatalf("expected second old host to disappear, got %v", got) } if got := h.ListFirstIPByHost("tail.test"); got != "5.6.7.8" { t.Fatalf("expected tail node to remain linked, got %q", got) } nodes := h.List() if len(nodes) != 2 || nodes[0].IP() != "5.6.7.8" || nodes[1].IP() != "1.2.3.4" { t.Fatalf("unexpected node list after SetIPHosts: %#v", nodes) } if nodes[0].NextUID() != nodes[1].UID() || nodes[1].LastUID() != nodes[0].UID() { t.Fatalf("remaining node linkage broken: first.next=%d second.uid=%d second.last=%d", nodes[0].NextUID(), nodes[1].UID(), nodes[1].LastUID()) } } func TestSetHostIPsReplacesMappingsInOneOperation(t *testing.T) { h := NewHosts() if err := h.AddHosts("1.2.3.4", "old.test"); err != nil { t.Fatal(err) } if err := h.SetHostIPs("old.test", "2.2.2.2", "3.3.3.3"); err != nil { t.Fatal(err) } if got := h.ListIPsByHost("old.test"); len(got) != 2 || got[0] != "2.2.2.2" || got[1] != "3.3.3.3" { t.Fatalf("expected replaced host ip mappings, got %v", got) } if got := h.ListHostsByIP("1.2.3.4"); len(got) != 0 { t.Fatalf("expected old ip mapping to be removed, got %v", got) } } func TestSetIPHostsRejectsInvalidInputWithoutMutating(t *testing.T) { tests := []struct { name string ip string hosts []string }{ {name: "bad ip", ip: "bad-ip", hosts: []string{"new.test"}}, {name: "empty host", ip: "1.2.3.4", hosts: []string{""}}, {name: "comment host", ip: "1.2.3.4", hosts: []string{"#bad.test"}}, {name: "missing host", ip: "1.2.3.4"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { h := NewHosts() if err := h.AddHosts("1.2.3.4", "old.test"); err != nil { t.Fatal(err) } if err := h.SetIPHosts(tt.ip, tt.hosts...); err == nil { t.Fatal("expected invalid SetIPHosts input to fail") } if got := h.ListIPsByHost("old.test"); len(got) != 1 || got[0] != "1.2.3.4" { t.Fatalf("old host mapping should remain after failed SetIPHosts, got %v", got) } if got := h.ListHostsByIP("1.2.3.4"); len(got) != 1 || got[0] != "old.test" { t.Fatalf("ip index should remain after failed SetIPHosts, got %v", got) } if got := h.ListIPsByHost("new.test"); len(got) != 0 { t.Fatalf("failed SetIPHosts should not add new host, got %v", got) } }) } } func TestSetHostIPsRejectsInvalidInputWithoutMutating(t *testing.T) { tests := []struct { name string host string ips []string }{ {name: "empty host", host: "", ips: []string{"2.2.2.2"}}, {name: "comment host", host: "#old.test", ips: []string{"2.2.2.2"}}, {name: "bad ip", host: "old.test", ips: []string{"2.2.2.2", "bad-ip"}}, {name: "missing ip", host: "old.test"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { h := NewHosts() if err := h.AddHosts("1.2.3.4", "old.test"); err != nil { t.Fatal(err) } if err := h.SetHostIPs(tt.host, tt.ips...); err == nil { t.Fatal("expected invalid SetHostIPs input to fail") } if got := h.ListIPsByHost("old.test"); len(got) != 1 || got[0] != "1.2.3.4" { t.Fatalf("old host mapping should remain after failed SetHostIPs, got %v", got) } if got := h.ListHostsByIP("1.2.3.4"); len(got) != 1 || got[0] != "old.test" { t.Fatalf("ip index should remain after failed SetHostIPs, got %v", got) } if got := h.ListHostsByIP("2.2.2.2"); len(got) != 0 { t.Fatalf("failed SetHostIPs should not add partial ip mapping, got %v", got) } }) } } func TestRemoveIPHostsKeepsSameIPOtherNodes(t *testing.T) { h := NewHosts() if err := h.AddHosts("1.2.3.4", "first.test"); err != nil { t.Fatal(err) } if err := h.AddHosts("1.2.3.4", "second.test"); err != nil { t.Fatal(err) } if err := h.RemoveIPHosts("1.2.3.4", "first.test"); err != nil { t.Fatal(err) } if got := h.ListIPsByHost("first.test"); len(got) != 0 { t.Fatalf("expected removed host to disappear, got %v", got) } if got := h.ListIPsByHost("second.test"); len(got) != 1 || got[0] != "1.2.3.4" { t.Fatalf("expected same-ip sibling node to stay indexed, got %v", got) } if got := h.ListHostsByIP("1.2.3.4"); len(got) != 1 || got[0] != "second.test" { t.Fatalf("expected ip index to keep sibling host, got %v", got) } } func TestRemoveHostsKeepsSameIPOtherNodes(t *testing.T) { h := NewHosts() if err := h.AddHosts("1.2.3.4", "first.test"); err != nil { t.Fatal(err) } if err := h.AddHosts("1.2.3.4", "second.test"); err != nil { t.Fatal(err) } if err := h.RemoveHosts("first.test"); err != nil { t.Fatal(err) } if got := h.ListHostsByIP("1.2.3.4"); len(got) != 1 || got[0] != "second.test" { t.Fatalf("expected ip index to keep sibling host after RemoveHosts, got %v", got) } } func TestRemoveIPsUnlinksAdjacentNodes(t *testing.T) { h := NewHosts() if err := h.AddHosts("1.2.3.4", "first.test"); err != nil { t.Fatal(err) } if err := h.AddHosts("1.2.3.4", "second.test"); err != nil { t.Fatal(err) } if err := h.AddHosts("5.6.7.8", "tail.test"); err != nil { t.Fatal(err) } if err := h.RemoveIPs("1.2.3.4"); err != nil { t.Fatal(err) } nodes := h.List() if len(nodes) != 1 || nodes[0].IP() != "5.6.7.8" || nodes[0].LastUID() != 0 || nodes[0].NextUID() != 0 { t.Fatalf("expected only tail node with clean links, got %#v", nodes) } if got := h.ListHostsByIP("1.2.3.4"); len(got) != 0 { t.Fatalf("expected removed ip index to be empty, got %v", got) } if got := h.ListFirstIPByHost("tail.test"); got != "5.6.7.8" { t.Fatalf("expected tail reverse index to remain, got %q", got) } } func TestAddHostsRejectsInvalidInput(t *testing.T) { h := NewHosts() if err := h.AddHosts("not-an-ip", "bad.test"); err == nil { t.Fatal("expected invalid ip error") } if got := h.ListHostsByIP("not-an-ip"); len(got) != 0 { t.Fatalf("invalid ip should not be indexed, got %v", got) } if err := h.AddHosts("1.2.3.4", ""); err == nil { t.Fatal("expected empty host error") } if got := h.ListHostsByIP("1.2.3.4"); len(got) != 0 { t.Fatalf("empty host should not be indexed, got %v", got) } } func TestInsertNodeByDataRejectsInvalidHostDataWithoutMutating(t *testing.T) { h := NewHosts() if err := h.AddHosts("2.2.2.2", "anchor.test"); err != nil { t.Fatal(err) } anchor, err := h.GetFirstNode() if err != nil { t.Fatal(err) } tests := []struct { name string ip string hosts []string }{ {name: "bad ip", ip: "not-an-ip", hosts: []string{"bad.test"}}, {name: "empty host", ip: "1.1.1.1", hosts: []string{""}}, {name: "comment host", ip: "1.1.1.1", hosts: []string{"#bad.test"}}, {name: "missing host", ip: "1.1.1.1"}, } for _, tt := range tests { if err := h.InsertNodeByData(anchor, false, "", tt.ip, tt.hosts...); err == nil { t.Fatalf("%s: expected error", tt.name) } } nodes := h.List() if len(nodes) != 1 || nodes[0].IP() != "2.2.2.2" { t.Fatalf("invalid insert should not mutate node list: %#v", nodes) } if got := h.ListHostsByIP("1.1.1.1"); len(got) != 0 { t.Fatalf("invalid insert should not mutate ip index: %v", got) } if err := h.InsertNodeByData(anchor, true, "comment-only", ""); err != nil { t.Fatalf("comment-only insert should remain valid: %v", err) } nodes = h.List() if len(nodes) != 2 || !nodes[0].OnlyComment() || nodes[1].IP() != "2.2.2.2" { t.Fatalf("comment-only insert mismatch: %#v", nodes) } } func TestInsertNodeByDataRejectsEmptyNodeWithoutMutating(t *testing.T) { h := NewHosts() if err := h.AddHosts("2.2.2.2", "anchor.test"); err != nil { t.Fatal(err) } anchor, err := h.GetFirstNode() if err != nil { t.Fatal(err) } if err := h.InsertNodeByData(anchor, true, "", ""); err == nil { t.Fatal("expected empty insert to fail") } nodes := h.List() if len(nodes) != 1 || nodes[0].IP() != "2.2.2.2" { t.Fatalf("empty insert should not mutate node list: %#v", nodes) } out, err := h.Build() if err != nil { t.Fatal(err) } if got, want := string(out), "2.2.2.2 anchor.test"+lineBreaker; got != want { t.Fatalf("empty insert should not change output: got %q want %q", got, want) } } func TestEmptyHostsBuildAndSaveAs(t *testing.T) { h := NewHosts() path := filepath.Join(t.TempDir(), "hosts.empty") if err := os.WriteFile(path, nil, 0o644); err != nil { t.Fatal(err) } if err := h.Parse(path); err != nil { t.Fatal(err) } out, err := h.Build() if err != nil { t.Fatal(err) } if len(out) != 0 { t.Fatalf("expected empty build output, got %q", out) } outPath := filepath.Join(t.TempDir(), "hosts.out") if err := h.SaveAs(outPath); err != nil { t.Fatal(err) } saved, err := os.ReadFile(outPath) if err != nil { t.Fatal(err) } if len(saved) != 0 { t.Fatalf("expected empty saved file, got %q", saved) } } func TestParsePreservesBlankAndRawLines(t *testing.T) { h := NewHosts() path := filepath.Join(t.TempDir(), "hosts.raw") input := []byte("127.0.0.1 localhost\n\nbadline\n# tail comment\n") if err := os.WriteFile(path, input, 0o644); err != nil { t.Fatal(err) } if err := h.Parse(path); err != nil { t.Fatal(err) } out, err := h.Build() if err != nil { t.Fatal(err) } got := string(out) if !strings.Contains(got, "127.0.0.1 localhost"+lineBreaker+lineBreaker+"badline"+lineBreaker) { t.Fatalf("expected blank/raw lines to be preserved, got %q", got) } if !strings.Contains(got, "# tail comment"+lineBreaker) { t.Fatalf("expected comment line to be preserved, got %q", got) } } func TestHostAccessorsReturnDetachedCopies(t *testing.T) { h := NewHosts() if err := h.AddHosts("1.2.3.4", "example.test"); err != nil { t.Fatal(err) } node, err := h.GetFirstNode() if err != nil { t.Fatal(err) } node.SetIP("9.9.9.9") node.SetHosts("mutated.test") if got := h.ListFirstIPByHost("example.test"); got != "1.2.3.4" { t.Fatalf("detached copy mutated internal host index: %q", got) } if got := h.ListFirstIPByHost("mutated.test"); got != "" { t.Fatalf("detached copy should not create new host index: %q", got) } out, err := h.Build() if err != nil { t.Fatal(err) } if strings.Contains(string(out), "9.9.9.9 mutated.test") { t.Fatalf("detached copy leaked into build output: %q", out) } } func TestUpdateNodeRejectsInvalidMutationAndPreservesState(t *testing.T) { h := NewHosts() if err := h.AddHosts("1.2.3.4", "example.test"); err != nil { t.Fatal(err) } node, err := h.GetFirstNode() if err != nil { t.Fatal(err) } node.SetIP("bad-ip") if err := h.UpdateNode(node); err == nil { t.Fatal("expected invalid update to fail") } if got := h.ListFirstIPByHost("example.test"); got != "1.2.3.4" { t.Fatalf("failed update should preserve previous index, got %q", got) } out, err := h.Build() if err != nil { t.Fatal(err) } if !strings.Contains(string(out), "1.2.3.4 example.test") || strings.Contains(string(out), "bad-ip") { t.Fatalf("failed update should preserve previous output, got %q", out) } } func TestUpdateNodeCommentOnlyStateReindexesCleanly(t *testing.T) { h := NewHosts() if err := h.AddHosts("1.2.3.4", "example.test"); err != nil { t.Fatal(err) } node, err := h.GetFirstNode() if err != nil { t.Fatal(err) } node.SetIP("") node.SetHosts() node.SetComment("note") if err := h.UpdateNode(node); err != nil { t.Fatalf("comment-only update failed: %v", err) } if got := h.ListByIP(""); len(got) != 0 { t.Fatalf("comment-only node should not be indexed under empty ip: %#v", got) } updated, err := h.GetNode(node.UID()) if err != nil { t.Fatal(err) } if !updated.OnlyComment() { t.Fatalf("comment-only node should keep onlyComment state: %#v", updated) } node = updated node.SetIP("2.2.2.2") node.SetHosts("restored.test") if err := h.UpdateNode(node); err != nil { t.Fatalf("restoring host entry failed: %v", err) } updated, err = h.GetNode(node.UID()) if err != nil { t.Fatal(err) } if updated.OnlyComment() { t.Fatalf("host entry should clear onlyComment after restore: %#v", updated) } if got := h.ListFirstIPByHost("restored.test"); got != "2.2.2.2" { t.Fatalf("restored host index mismatch: %q", got) } if got := h.ListByIP(""); len(got) != 0 { t.Fatalf("restored host should still avoid empty ip index: %#v", got) } }