//go:build unix package hostguard import ( "errors" "os" "path/filepath" "reflect" "testing" "golang.org/x/sys/unix" ) func TestRetireInstallationWithSyncsInactiveRecord(t *testing.T) { f := newGuardFixture(t) host := f.obs.Host if err := f.g.Activate(t.Context(), host, "uk", nil); err != nil { t.Fatal(err) } captured, err := Capture(f.g.DataDir, host) if err != nil { t.Fatal(err) } failure := errors.New("injected retirement sync failure") failKind := "directory" var calls []string f.g.openRecords = func(path string) (*recordDir, error) { d, err := openRecordDir(path) if err != nil { return nil, err } d.fsync = func(fd int) error { kind := "file" if fd == d.fd { kind = "directory" } calls = append(calls, kind) if kind == failKind { return failure } return unix.Fsync(fd) } return d, nil } callbacks := 0 retire := func() error { return f.g.RetireInstallationWith(t.Context(), host, captured, allow, func() error { callbacks++ return nil }) } if err := retire(); !errors.Is(err, failure) || callbacks != 0 { t.Fatalf("initial failure: %v, callbacks=%d", err, callbacks) } path := filepath.Join(f.g.DataDir, "hostguard", host+".json") // Hold the inode so replacement writes cannot reuse its number. original, err := os.Open(path) if err != nil { t.Fatal(err) } defer original.Close() defer unchangedFile(t, path)() for _, kind := range []string{"file", "directory", ""} { failKind, calls = kind, nil err := retire() if kind == "" { if err != nil || callbacks != 1 { t.Fatalf("durable retry: %v, callbacks=%d", err, callbacks) } } else if !errors.Is(err, failure) || callbacks != 0 { t.Fatalf("%s failure: %v, callbacks=%d", kind, err, callbacks) } want := []string{"file", "directory"} if kind == "file" { want = []string{"file"} } if !reflect.DeepEqual(calls, want) { t.Fatalf("syncs=%v, want=%v", calls, want) } } } func TestRetireInstallationWithRepairsDurability(t *testing.T) { f := newGuardFixture(t) if err := f.g.Activate(t.Context(), f.obs.Host, "uk", nil); err != nil { t.Fatal(err) } captured, err := Capture(f.g.DataDir, f.obs.Host) if err != nil { t.Fatal(err) } crash := errors.New("crash after record rename before directory fsync") callbacks, writes := 0, 0 callback := func() error { callbacks++; return nil } persist := func(d *recordDir, r record) error { writes++ if r.ActiveStamp != 0 || len(r.RetiredStamps) != 1 || r.RetiredStamps[0] != captured.Stamp { t.Fatal("incorrect inactive record", r) } return d.writeUsing(r, ".retire-crash", func(a int, old string, b int, new string) error { if err := unix.Renameat(a, old, b, new); err != nil { return err } return crash }) } for attempt := 0; attempt < 2; attempt++ { if err := f.g.retireInstallationWith(t.Context(), f.obs.Host, captured, allow, callback, persist); !errors.Is(err, crash) { t.Fatalf("attempt %d: %v", attempt, err) } if callbacks != 0 || writes != attempt+1 { t.Fatal("callback ran without durable record", callbacks, writes) } visible, err := Capture(f.g.DataDir, f.obs.Host) if err != nil || visible.Stamp != 0 { t.Fatal("did not reach rename boundary", visible, err) } } // Exercise the public entry point with its real writer after both failures. if err := f.g.RetireInstallationWith(t.Context(), f.obs.Host, captured, allow, callback); err != nil { t.Fatal(err) } if callbacks != 1 { t.Fatal("durable retry did not invoke callback") } }