| 1 | //go:build unix |
| 2 | |
| 3 | package hostguard |
| 4 | |
| 5 | import ( |
| 6 | "errors" |
| 7 | "os" |
| 8 | "path/filepath" |
| 9 | "reflect" |
| 10 | "testing" |
| 11 | |
| 12 | "golang.org/x/sys/unix" |
| 13 | ) |
| 14 | |
| 15 | func TestRetireInstallationWithSyncsInactiveRecord(t *testing.T) { |
| 16 | f := newGuardFixture(t) |
| 17 | host := f.obs.Host |
| 18 | if err := f.g.Activate(t.Context(), host, "uk", nil); err != nil { |
| 19 | t.Fatal(err) |
| 20 | } |
| 21 | captured, err := Capture(f.g.DataDir, host) |
| 22 | if err != nil { |
| 23 | t.Fatal(err) |
| 24 | } |
| 25 | failure := errors.New("injected retirement sync failure") |
| 26 | failKind := "directory" |
| 27 | var calls []string |
| 28 | f.g.openRecords = func(path string) (*recordDir, error) { |
| 29 | d, err := openRecordDir(path) |
| 30 | if err != nil { |
| 31 | return nil, err |
| 32 | } |
| 33 | d.fsync = func(fd int) error { |
| 34 | kind := "file" |
| 35 | if fd == d.fd { |
| 36 | kind = "directory" |
| 37 | } |
| 38 | calls = append(calls, kind) |
| 39 | if kind == failKind { |
| 40 | return failure |
| 41 | } |
| 42 | return unix.Fsync(fd) |
| 43 | } |
| 44 | return d, nil |
| 45 | } |
| 46 | callbacks := 0 |
| 47 | retire := func() error { |
| 48 | return f.g.RetireInstallationWith(t.Context(), host, captured, allow, func() error { |
| 49 | callbacks++ |
| 50 | return nil |
| 51 | }) |
| 52 | } |
| 53 | if err := retire(); !errors.Is(err, failure) || callbacks != 0 { |
| 54 | t.Fatalf("initial failure: %v, callbacks=%d", err, callbacks) |
| 55 | } |
| 56 | path := filepath.Join(f.g.DataDir, "hostguard", host+".json") |
| 57 | // Hold the inode so replacement writes cannot reuse its number. |
| 58 | original, err := os.Open(path) |
| 59 | if err != nil { |
| 60 | t.Fatal(err) |
| 61 | } |
| 62 | defer original.Close() |
| 63 | defer unchangedFile(t, path)() |
| 64 | for _, kind := range []string{"file", "directory", ""} { |
| 65 | failKind, calls = kind, nil |
| 66 | err := retire() |
| 67 | if kind == "" { |
| 68 | if err != nil || callbacks != 1 { |
| 69 | t.Fatalf("durable retry: %v, callbacks=%d", err, callbacks) |
| 70 | } |
| 71 | } else if !errors.Is(err, failure) || callbacks != 0 { |
| 72 | t.Fatalf("%s failure: %v, callbacks=%d", kind, err, callbacks) |
| 73 | } |
| 74 | want := []string{"file", "directory"} |
| 75 | if kind == "file" { |
| 76 | want = []string{"file"} |
| 77 | } |
| 78 | if !reflect.DeepEqual(calls, want) { |
| 79 | t.Fatalf("syncs=%v, want=%v", calls, want) |
| 80 | } |
| 81 | } |
| 82 | } |
| 83 | |
| 84 | func TestRetireInstallationWithRepairsDurability(t *testing.T) { |
| 85 | f := newGuardFixture(t) |
| 86 | if err := f.g.Activate(t.Context(), f.obs.Host, "uk", nil); err != nil { |
| 87 | t.Fatal(err) |
| 88 | } |
| 89 | captured, err := Capture(f.g.DataDir, f.obs.Host) |
| 90 | if err != nil { |
| 91 | t.Fatal(err) |
| 92 | } |
| 93 | crash := errors.New("crash after record rename before directory fsync") |
| 94 | callbacks, writes := 0, 0 |
| 95 | callback := func() error { callbacks++; return nil } |
| 96 | persist := func(d *recordDir, r record) error { |
| 97 | writes++ |
| 98 | if r.ActiveStamp != 0 || len(r.RetiredStamps) != 1 || r.RetiredStamps[0] != captured.Stamp { |
| 99 | t.Fatal("incorrect inactive record", r) |
| 100 | } |
| 101 | return d.writeUsing(r, ".retire-crash", func(a int, old string, b int, new string) error { |
| 102 | if err := unix.Renameat(a, old, b, new); err != nil { |
| 103 | return err |
| 104 | } |
| 105 | return crash |
| 106 | }) |
| 107 | } |
| 108 | for attempt := 0; attempt < 2; attempt++ { |
| 109 | if err := f.g.retireInstallationWith(t.Context(), f.obs.Host, captured, allow, callback, persist); !errors.Is(err, crash) { |
| 110 | t.Fatalf("attempt %d: %v", attempt, err) |
| 111 | } |
| 112 | if callbacks != 0 || writes != attempt+1 { |
| 113 | t.Fatal("callback ran without durable record", callbacks, writes) |
| 114 | } |
| 115 | visible, err := Capture(f.g.DataDir, f.obs.Host) |
| 116 | if err != nil || visible.Stamp != 0 { |
| 117 | t.Fatal("did not reach rename boundary", visible, err) |
| 118 | } |
| 119 | } |
| 120 | // Exercise the public entry point with its real writer after both failures. |
| 121 | if err := f.g.RetireInstallationWith(t.Context(), f.obs.Host, captured, allow, callback); err != nil { |
| 122 | t.Fatal(err) |
| 123 | } |
| 124 | if callbacks != 1 { |
| 125 | t.Fatal("durable retry did not invoke callback") |
| 126 | } |
| 127 | } |
| 128 | |