| new file mode 100644 |
| index 000000000..4b1c8450f |
| --- /dev/null |
| +++ b/internal/hostguard/record_test.go |
| @@ -0,0 +1,256 @@ |
| +//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) |
| + } |
| + }) |
| + } |
| +} |