//go:build unix package hostguard import ( "errors" "fmt" "io" "os" "path/filepath" "strings" "sync" "testing" "golang.org/x/sys/unix" ) func recordFixture(t *testing.T) string { t.Helper() dir := t.TempDir() if err := os.Mkdir(filepath.Join(dir, "hostguard"), 0770); err != nil { t.Fatal(err) } return dir } func TestCaptureStrict(t *testing.T) { dir := recordFixture(t) host := "shop.example" if _, err := Capture(dir, host); !errors.Is(err, ErrNoRecord) { t.Fatal(err) } if _, err := Capture(dir, "../outside"); !errors.Is(err, ErrInvalidHost) { t.Fatal(err) } good := `{"v":1,"host":"shop.example","incarnation":"inc","active_stamp":3,"retired_stamps":[1,2]}` for _, tc := range []struct { name, body string valid bool }{ {"good", good, true}, {"unknown", strings.Replace(good, `"v":1`, `"v":1,"future":true`, 1), false}, {"trailing", good + `{}`, false}, {"trailing-garbage", good + `!`, false}, {"version", strings.Replace(good, `"v":1`, `"v":2`, 1), false}, {"host", strings.Replace(good, "shop.example", "other.example", 1), false}, {"incarnation", strings.Replace(good, `"inc"`, `""`, 1), false}, {"active-retired", strings.Replace(good, `[1,2]`, `[1,3]`, 1), false}, {"zero-retired", strings.Replace(good, `[1,2]`, `[0,2]`, 1), false}, {"duplicate", strings.Replace(good, `[1,2]`, `[1,1]`, 1), false}, {"unordered", strings.Replace(good, `[1,2]`, `[2,1]`, 1), false}, {"null", `null`, false}, {"truncated", good[:len(good)-1], false}, {"negative", strings.Replace(good, `"active_stamp":3`, `"active_stamp":-1`, 1), false}, } { t.Run(tc.name, func(t *testing.T) { if err := os.WriteFile(filepath.Join(dir, "hostguard", host+".json"), []byte(tc.body), 0660); err != nil { t.Fatal(err) } got, err := Capture(dir, host) if tc.valid { if err != nil || got != (Captured{Incarnation: "inc", Stamp: 3}) { t.Fatalf("%+v %v", got, err) } } else if !errors.Is(err, ErrMalformedRecord) { t.Fatal(err) } }) } } func TestRecordAtomicRename(t *testing.T) { dir, host := recordFixture(t), "shop.example" r := record{V: 1, Host: host, Incarnation: "inc", ActiveStamp: 1, RetiredStamps: []uint64{}} if err := writeRecord(dir, r); err != nil { t.Fatal(err) } want := errors.New("rename failed") r.ActiveStamp = 2 d, err := openRecordDir(dir) if err != nil { t.Fatal(err) } defer d.close() err = d.writeUsing(r, ".rename-test", func(srcFD int, src string, dstFD int, dst string) error { if srcFD != d.fd || dstFD != d.fd || src != ".rename-test" || dst != host+".json" { t.Fatal("rename escaped pinned directory") } fd, e := unix.Openat(srcFD, src, unix.O_RDONLY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) if e != nil { t.Fatal(e) } f := os.NewFile(uintptr(fd), src) b, e := io.ReadAll(f) f.Close() if e != nil || !strings.Contains(string(b), `"active_stamp":2`) { t.Fatalf("temporary file: %s %v", b, e) } return want }) if !errors.Is(err, want) { t.Fatal(err) } got, err := Capture(dir, host) if err != nil || got.Stamp != 1 { t.Fatalf("old record: %+v %v", got, err) } files, err := os.ReadDir(filepath.Join(dir, "hostguard")) if err != nil || len(files) != 1 || files[0].Name() != host+".json" { t.Fatalf("leftovers: %v %v", files, err) } var wg sync.WaitGroup errs := make(chan error, 1) done := make(chan struct{}) wg.Add(1) go func() { defer wg.Done() for { select { case <-done: return default: } c, e := Capture(dir, host) if e != nil || c.Incarnation != "inc" || c.Stamp == 0 { select { case errs <- fmt.Errorf("torn capture: %+v %v", c, e): default: } return } } }() for i := uint64(2); i <= 40; i++ { r.ActiveStamp = i if err := writeRecord(dir, r); err != nil { close(done) wg.Wait() t.Fatal(err) } } close(done) wg.Wait() select { case err := <-errs: t.Fatal(err) default: } } func TestRecordRejectsOversizedHistoryAndReadError(t *testing.T) { r := record{V: 1, Host: "shop.example", Incarnation: "inc"} for i := uint64(1); i <= 33; i++ { r.RetiredStamps = append(r.RetiredStamps, i) } if !errors.Is(r.validate(r.Host), ErrMalformedRecord) { t.Fatal("accepted 33 stamps") } dir := t.TempDir() if err := os.MkdirAll(filepath.Join(dir, "hostguard", r.Host+".json"), 0700); err != nil { t.Fatal(err) } if _, err := Capture(dir, r.Host); err == nil || errors.Is(err, ErrNoRecord) { t.Fatalf("directory became absence: %v", err) } } func unchangedFile(t *testing.T, path string) func() { t.Helper() before, err := os.Stat(path) if err != nil { t.Fatal(err) } body, err := os.ReadFile(path) if err != nil { t.Fatal(err) } return func() { t.Helper() after, err := os.Stat(path) if err != nil { t.Fatal(err) } got, err := os.ReadFile(path) if err != nil || string(got) != string(body) || before.Mode() != after.Mode() || !os.SameFile(before, after) { t.Fatalf("target changed: before=%v after=%v body=%q err=%v", before, after, got, err) } } } func TestRecordNotProvisioned(t *testing.T) { dir := t.TempDir() r := record{V: 1, Host: "shop.example", Incarnation: "inc", ActiveStamp: 1} if _, err := Capture(dir, r.Host); !errors.Is(err, ErrNotProvisioned) || errors.Is(err, ErrNoRecord) { t.Fatal(err) } if err := writeRecord(dir, r); !errors.Is(err, ErrNotProvisioned) { t.Fatal(err) } if _, err := os.Lstat(filepath.Join(dir, "hostguard")); !errors.Is(err, os.ErrNotExist) { t.Fatalf("library created directory: %v", err) } } func TestRecordSymlinksRefused(t *testing.T) { for _, kind := range []string{"directory", "record", "temp"} { t.Run(kind, func(t *testing.T) { dir := recordFixture(t) host := "shop.example" r := record{V: 1, Host: host, Incarnation: "inc", ActiveStamp: 2} outside := t.TempDir() target := filepath.Join(outside, host+".json") if err := os.WriteFile(target, []byte(`{"v":1,"host":"shop.example","incarnation":"inc","active_stamp":1,"retired_stamps":[]}`), 0600); err != nil { t.Fatal(err) } defer unchangedFile(t, target)() guardDir := filepath.Join(dir, "hostguard") link, destination := filepath.Join(guardDir, host+".json"), target if kind == "directory" { if err := os.Remove(guardDir); err != nil { t.Fatal(err) } link, destination = guardDir, outside } else if kind == "temp" { link = filepath.Join(guardDir, ".fixed-temp") } if err := os.Symlink(destination, link); err != nil { t.Fatal(err) } if kind == "temp" { d, err := openRecordDir(dir) if err != nil { t.Fatal(err) } defer d.close() if err := d.writeUsing(r, ".fixed-temp", unix.Renameat); err == nil { t.Fatal("accepted temporary symlink") } if _, err := d.read(host); !errors.Is(err, ErrNoRecord) { t.Fatal("published record despite refused temporary file", err) } } else { if _, err := Capture(dir, host); err == nil || errors.Is(err, ErrNoRecord) { t.Fatal("capture followed symlink", err) } if err := writeRecord(dir, r); err == nil { t.Fatal("write accepted symlink") } } if got, err := os.Readlink(link); err != nil || got != destination { t.Fatalf("symlink removed or replaced: %q %v", got, err) } }) } }