The tools were TypeScript; the mesh's modules are Go. The write to /etc/hosts is now staged and moved into place rather than written over the live file.
287 lines
9.4 KiB
Go
287 lines
9.4 KiB
Go
package main
|
|
|
|
// The hosts file's verbs over files shaped like the workstation's on 2026-10-03 (novox/hq ADR 0199):
|
|
// distribution lines, an operator's development names, the mesh's block and another tool's.
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
const file = "# Static table lookup for hostnames.\n" +
|
|
"127.0.0.1\tlocaldev.example.com\n" +
|
|
"127.0.0.1 a.example.com b.example.com\n" +
|
|
"# BEGIN mesh hosts.own\n" +
|
|
"127.0.0.1\tlocalhost\n" +
|
|
"::1\tlocalhost\n" +
|
|
"# END mesh hosts.own\n" +
|
|
"# BEGIN other-tool\n" +
|
|
"192.0.2.7\tproject.test\n" +
|
|
"# END other-tool\n"
|
|
|
|
func blocks(text string) []string {
|
|
var out []string
|
|
for _, l := range Parse(text) {
|
|
if l.Owner != Operator {
|
|
out = append(out, l.Text)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func TestEveryLineSaysWhoseItIs(t *testing.T) {
|
|
lines := Parse(file)
|
|
if len(lines) != 10 {
|
|
t.Fatalf("%d lines: %+v", len(lines), lines)
|
|
}
|
|
want := Line{Text: "127.0.0.1\tlocaldev.example.com", Owner: Operator, Address: "127.0.0.1", Names: []string{"localdev.example.com"}}
|
|
if !reflect.DeepEqual(lines[1], want) {
|
|
t.Errorf("%+v", lines[1])
|
|
}
|
|
if lines[0].Address != "" || lines[0].Owner != Operator {
|
|
t.Errorf("a comment is the operator's and no entry: %+v", lines[0])
|
|
}
|
|
if lines[3].Owner != "mesh hosts.own" || lines[4].Owner != "mesh hosts.own" || lines[6].Owner != "mesh hosts.own" {
|
|
t.Errorf("the mesh's block, its markers included: %+v", lines[3:7])
|
|
}
|
|
if lines[8].Owner != "other-tool" || !reflect.DeepEqual(lines[8].Names, []string{"project.test"}) {
|
|
t.Errorf("%+v", lines[8])
|
|
}
|
|
if lines[5].Address != "::1" {
|
|
t.Errorf("an IPv6 entry: %+v", lines[5])
|
|
}
|
|
}
|
|
|
|
func TestAnEntryWithATrailingCommentKeepsItsNames(t *testing.T) {
|
|
l := Parse("10.0.0.1 nas.lan # the box upstairs")[0]
|
|
if l.Address != "10.0.0.1" || !reflect.DeepEqual(l.Names, []string{"nas.lan"}) {
|
|
t.Errorf("%+v", l)
|
|
}
|
|
}
|
|
|
|
func TestAnUnclosedBlockHoldsTheRestOfTheFile(t *testing.T) {
|
|
lines := Parse("# BEGIN x\n10.0.0.1 a.test\n# END y\n10.0.0.2 b.test\n")
|
|
for _, l := range lines {
|
|
if l.Owner != "x" {
|
|
t.Errorf("%+v", l)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAddAppendsAnOperatorLineAndIsANoOpWhenTheNamesAreThere(t *testing.T) {
|
|
after, err := WithAdded(file, "192.0.2.9", []string{"lab.test", "www.lab.test"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.HasSuffix(after, "192.0.2.9\tlab.test www.lab.test\n") {
|
|
t.Errorf("%q", after)
|
|
}
|
|
if !reflect.DeepEqual(blocks(after), blocks(file)) {
|
|
t.Errorf("blocks changed")
|
|
}
|
|
if same, _ := WithAdded(file, "127.0.0.1", []string{"a.example.com"}); same != file {
|
|
t.Errorf("a name already there changed the file")
|
|
}
|
|
if some, _ := WithAdded(file, "127.0.0.1", []string{"a.example.com", "c.example.com"}); !strings.HasSuffix(some, "127.0.0.1\tc.example.com\n") {
|
|
t.Errorf("%q", some)
|
|
}
|
|
if ended, _ := WithAdded("127.0.0.1 localhost", "192.0.2.1", []string{"x.test"}); ended != "127.0.0.1 localhost\n192.0.2.1\tx.test\n" {
|
|
t.Errorf("a file without a last newline: %q", ended)
|
|
}
|
|
if empty, _ := WithAdded("", "192.0.2.1", []string{"x.test"}); empty != "192.0.2.1\tx.test\n" {
|
|
t.Errorf("an empty file: %q", empty)
|
|
}
|
|
}
|
|
|
|
func TestAddDoesNotCountANameOnlyABlockHasAsTheOperators(t *testing.T) {
|
|
after, err := WithAdded(file, "127.0.0.1", []string{"localhost"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.HasSuffix(after, "# END other-tool\n127.0.0.1\tlocalhost\n") {
|
|
t.Errorf("%q", after)
|
|
}
|
|
}
|
|
|
|
func TestAddRefusesWhatIsNotAnAddressOrAHostName(t *testing.T) {
|
|
for _, c := range []struct {
|
|
address string
|
|
names []string
|
|
says string
|
|
}{
|
|
{"not-an-ip", []string{"x.test"}, "not an IPv4 or IPv6 address"},
|
|
{"192.0.2.9", []string{"bad name\n10.0.0.1 evil"}, "not a host name"},
|
|
{"192.0.2.9", []string{"-lead.test"}, "not a host name"},
|
|
{"192.0.2.9", []string{strings.Repeat("a", 64) + ".test"}, "not a host name"},
|
|
{"192.0.2.9", []string{strings.Repeat("abcdefgh.", 30)}, "not a host name"},
|
|
{"192.0.2.9", []string{}, "at least one name"},
|
|
} {
|
|
if _, err := WithAdded(file, c.address, c.names); err == nil || !strings.Contains(err.Error(), c.says) {
|
|
t.Errorf("%q %q: %v", c.address, c.names, err)
|
|
}
|
|
}
|
|
if _, err := WithAdded(file, "2001:db8::1", []string{"v6.test."}); err != nil {
|
|
t.Errorf("an IPv6 address and a rooted name: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRemoveTakesOneNameOrOneAddressAndBlocksStayByteForByte(t *testing.T) {
|
|
one, n, err := WithRemoved(file, "a.example.com")
|
|
if err != nil || n != 1 {
|
|
t.Fatalf("%d %v", n, err)
|
|
}
|
|
if !strings.Contains(one, "127.0.0.1\tb.example.com\n") || strings.Contains(one, "a.example.com") {
|
|
t.Errorf("%q", one)
|
|
}
|
|
if !reflect.DeepEqual(blocks(one), blocks(file)) {
|
|
t.Errorf("blocks changed")
|
|
}
|
|
all, n, err := WithRemoved(file, "127.0.0.1")
|
|
if err != nil || n != 2 {
|
|
t.Fatalf("%d %v", n, err)
|
|
}
|
|
if !strings.Contains(all, "# BEGIN mesh hosts.own\n127.0.0.1\tlocalhost\n") {
|
|
t.Errorf("the mesh's own localhost is not the operator's to remove: %q", all)
|
|
}
|
|
if !reflect.DeepEqual(blocks(all), blocks(file)) {
|
|
t.Errorf("blocks changed")
|
|
}
|
|
}
|
|
|
|
func TestRemoveRefusesANameOnlyABlockWritesNamingWhose(t *testing.T) {
|
|
if _, _, err := WithRemoved(file, "project.test"); err == nil || !strings.Contains(err.Error(), "written by other-tool") {
|
|
t.Errorf("%v", err)
|
|
}
|
|
if _, _, err := WithRemoved(file, "::1"); err == nil || !strings.Contains(err.Error(), "written by mesh hosts.own") {
|
|
t.Errorf("%v", err)
|
|
}
|
|
if _, n, err := WithRemoved(file, "nowhere.test"); err != nil || n != 0 {
|
|
t.Errorf("%d %v", n, err)
|
|
}
|
|
if _, _, err := WithRemoved(file, "bad name"); err == nil {
|
|
t.Errorf("a name that is neither an address nor a host name is refused")
|
|
}
|
|
}
|
|
|
|
func TestTheFileIsWrittenAsRootThroughSudoWhereTheAccountIsNotRoot(t *testing.T) {
|
|
if p, a := escalated(1000, "install", []string{"x"}); p != "sudo" || !reflect.DeepEqual(a, []string{"-n", "install", "x"}) {
|
|
t.Errorf("%s %v", p, a)
|
|
}
|
|
if p, a := escalated(0, "install", []string{"x"}); p != "install" || !reflect.DeepEqual(a, []string{"x"}) {
|
|
t.Errorf("%s %v", p, a)
|
|
}
|
|
}
|
|
|
|
// runPlain runs a command as given, unescalated: the test's file is the test's own.
|
|
func runPlain(ctx context.Context, name string, args ...string) (string, error) {
|
|
out, err := exec.CommandContext(ctx, name, args...).CombinedOutput()
|
|
return string(out), err
|
|
}
|
|
|
|
func TestTheVerbsWriteTheFileWholeBesideItAndRenameItOver(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "hosts")
|
|
if err := os.WriteFile(path, []byte(file), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var calls [][]string
|
|
h := HostsFile{Path: path, Run: func(ctx context.Context, name string, args ...string) (string, error) {
|
|
calls = append(calls, append([]string{name}, args...))
|
|
return runPlain(ctx, name, args...)
|
|
}}
|
|
ctx := context.Background()
|
|
added, err := h.Add(ctx, "192.0.2.9", []string{"lab.test"})
|
|
if err != nil || !added.Added || added.Line != "192.0.2.9\tlab.test" {
|
|
t.Fatalf("%+v %v", added, err)
|
|
}
|
|
beside := filepath.Join(dir, ".hosts.hosts-tools")
|
|
if len(calls) != 2 || calls[0][0] != "install" || calls[0][len(calls[0])-1] != beside ||
|
|
!reflect.DeepEqual(calls[1], []string{"mv", "-f", beside, path}) {
|
|
t.Errorf("%v", calls)
|
|
}
|
|
if again, _ := h.Add(ctx, "192.0.2.9", []string{"lab.test"}); again.Added || len(calls) != 2 {
|
|
t.Errorf("an add already there wrote the file: %+v %v", again, calls)
|
|
}
|
|
got, err := h.Entries()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
last := got.Lines[len(got.Lines)-1]
|
|
if last.Owner != Operator || last.Address != "192.0.2.9" {
|
|
t.Errorf("add then entries shows the line as the operator's: %+v", last)
|
|
}
|
|
removed, err := h.Remove(ctx, "lab.test")
|
|
if err != nil || removed.Removed != 1 {
|
|
t.Fatalf("%+v %v", removed, err)
|
|
}
|
|
if b, _ := os.ReadFile(path); string(b) != file {
|
|
t.Errorf("add then remove gives the file back: %q", b)
|
|
}
|
|
if _, err := os.Stat(beside); !os.IsNotExist(err) {
|
|
t.Errorf("the staged copy beside the file is left: %v", err)
|
|
}
|
|
if info, _ := os.Stat(path); info.Mode().Perm() != 0o644 {
|
|
t.Errorf("mode %v", info.Mode())
|
|
}
|
|
}
|
|
|
|
func TestThePathTheCodeWritesIsThePathTheManifestsResourceDeclares(t *testing.T) {
|
|
raw, err := os.ReadFile("../../module.json")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var m struct {
|
|
Claims []struct {
|
|
Name string `json:"name"`
|
|
Serves []string `json:"serves"`
|
|
} `json:"claims"`
|
|
Resources []struct {
|
|
ID string `json:"id"`
|
|
Path string `json:"path"`
|
|
} `json:"resources"`
|
|
}
|
|
if err := json.Unmarshal(raw, &m); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
found := false
|
|
for _, r := range m.Resources {
|
|
if r.ID == "own" {
|
|
found = true
|
|
if r.Path != HostsPath {
|
|
t.Errorf("the manifest writes %s, the code %s", r.Path, HostsPath)
|
|
}
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("no resource own")
|
|
}
|
|
var served []string
|
|
for _, tool := range tools(HostsFile{}) {
|
|
served = append(served, strings.TrimPrefix(tool.Name, Seat+"."))
|
|
if tool.Description == "" || tool.Run == nil {
|
|
t.Errorf("%s", tool.Name)
|
|
}
|
|
}
|
|
if len(m.Claims) != 1 || m.Claims[0].Name != Seat || !reflect.DeepEqual(m.Claims[0].Serves, served) {
|
|
t.Errorf("the manifest serves %+v, the binary %v", m.Claims, served)
|
|
}
|
|
}
|
|
|
|
func TestNamesAreSplitOnSpacesAndCommasOrTakenAsAList(t *testing.T) {
|
|
if got := namesArg(map[string]any{"names": " a.test, b.test c.test"}); !reflect.DeepEqual(got, []string{"a.test", "b.test", "c.test"}) {
|
|
t.Errorf("%v", got)
|
|
}
|
|
if got := namesArg(map[string]any{"names": []any{"a.test", "b.test"}}); !reflect.DeepEqual(got, []string{"a.test", "b.test"}) {
|
|
t.Errorf("%v", got)
|
|
}
|
|
if got := namesArg(map[string]any{}); len(got) != 0 {
|
|
t.Errorf("%v", got)
|
|
}
|
|
}
|