//go:build linux package stopevidence import ( "errors" "os" "path/filepath" "slices" "strconv" "syscall" "testing" ) // holdersFixture is a fake /proc: pid 20 (the agent) holds the database // at fd 7 and its WAL at fd 8 in its leader table, and an unrelated file // at fd 9; pid 30 holds nothing watched. Each pid has a stat file whose // field 22 is "start-". func holdersFixture(t *testing.T) (root, db string) { t.Helper() root, dir := t.TempDir(), t.TempDir() db = filepath.Join(dir, "data.db") for _, name := range []string{db, db + "-wal", filepath.Join(dir, "other")} { if err := os.WriteFile(name, nil, 0o600); err != nil { t.Fatal(err) } } link := func(pid, tid, fd int, target string) { fdDir := filepath.Join(root, strconv.Itoa(pid), "task", strconv.Itoa(tid), "fd") if err := os.MkdirAll(fdDir, 0o700); err != nil { t.Fatal(err) } if err := os.Symlink(target, filepath.Join(fdDir, strconv.Itoa(fd))); err != nil { t.Fatal(err) } } link(20, 20, 7, db) link(20, 20, 8, db+"-wal") link(20, 20, 9, filepath.Join(dir, "other")) link(30, 30, 3, filepath.Join(dir, "other")) for _, pid := range []int{20, 30} { writeStat(t, root, pid, "start-"+strconv.Itoa(pid)) } return root, db } func writeStat(t *testing.T, root string, pid int, start string) { t.Helper() fields := "S" for i := 4; i <= 21; i++ { fields += " 0" } body := strconv.Itoa(pid) + " (carlos) " + fields + " " + start + " 0 0\n" if err := os.WriteFile(filepath.Join(root, strconv.Itoa(pid), "stat"), []byte(body), 0o600); err != nil { t.Fatal(err) } } func identity(t *testing.T, path string) (uint64, uint64) { t.Helper() fi, err := os.Stat(path) if err != nil { t.Fatal(err) } st := fi.Sys().(*syscall.Stat_t) return uint64(st.Dev), st.Ino } func dbPathsOf(db string) []string { return []string{db, db + "-wal", db + "-shm"} } func TestHoldersReportsEveryDescriptorWithItsIdentity(t *testing.T) { root, db := holdersFixture(t) got, err := Holders(t.Context(), root, dbPathsOf(db), nil) if err != nil { t.Fatal(err) } dev, ino := identity(t, db) wdev, wino := identity(t, db+"-wal") want := []Descriptor{{PID: 20, TID: 20, FD: 7, Device: dev, Inode: ino}, {PID: 20, TID: 20, FD: 8, Device: wdev, Inode: wino}} if !slices.Equal(got, want) { t.Fatalf("got %+v want %+v", got, want) } // A file named only by its identity (the launch journal's record) is // found under whatever name it was opened by. byID, err := Holders(t.Context(), root, nil, []FileIdentity{{Device: dev, Inode: ino}}) if err != nil || !slices.Equal(byID, want[:1]) { t.Fatalf("by identity: %+v %v", byID, err) } if _, err := Holders(t.Context(), root, nil, nil); !errors.Is(err, ErrInvalidInput) { t.Fatalf("nothing watched: %v", err) } if _, err := Holders(t.Context(), filepath.Join(root, "missing"), dbPathsOf(db), nil); !errors.Is(err, ErrIncomplete) { t.Fatalf("unreadable root: %v", err) } } func TestOwnDescriptorsReadsOnlyTheLeaderTable(t *testing.T) { root, db := holdersFixture(t) // Another process's table is never read: pid 30 has no stat for this // call to fail on, and its descriptors never appear. got, err := OwnDescriptors(t.Context(), root, 20, dbPathsOf(db)) if err != nil { t.Fatal(err) } dev, ino := identity(t, db) wdev, wino := identity(t, db+"-wal") want := []DescriptorHint{{FD: 7, Device: dev, Inode: ino}, {FD: 8, Device: wdev, Inode: wino}} if !slices.Equal(got, want) { t.Fatalf("got %+v want %+v", got, want) } if err := os.Chmod(filepath.Join(root, "20", "task", "20", "fd"), 0); err != nil { t.Fatal(err) } defer os.Chmod(filepath.Join(root, "20", "task", "20", "fd"), 0o700) if os.Geteuid() != 0 { if _, err := OwnDescriptors(t.Context(), root, 20, dbPathsOf(db)); !errors.Is(err, ErrIncomplete) { t.Fatalf("an unreadable table is not an empty one: %v", err) } } } func TestReplicatorOnlyIsANarrowingHint(t *testing.T) { root, db := holdersFixture(t) dev, ino := identity(t, db) wdev, wino := identity(t, db+"-wal") agent := Agent{PID: 20, Start: "start-20"} hints := []DescriptorHint{{FD: 7, Device: dev, Inode: ino}, {FD: 8, Device: wdev, Inode: wino}} held := func(t *testing.T) []Descriptor { got, err := Holders(t.Context(), root, dbPathsOf(db), nil) if err != nil { t.Fatal(err) } return got } t.Run("every holder is a hinted replicator descriptor", func(t *testing.T) { got, err := ReplicatorOnly(t.Context(), root, held(t), agent, hints) if err != nil || len(got) != 2 { t.Fatalf("%+v %v", got, err) } }) t.Run("an unhinted descriptor in the agent itself", func(t *testing.T) { if _, err := ReplicatorOnly(t.Context(), root, held(t), agent, hints[:1]); !errors.Is(err, ErrForeignHolder) { t.Fatalf("got %v", err) } }) t.Run("a hint naming another file at the same number", func(t *testing.T) { wrong := []DescriptorHint{{FD: 7, Device: wdev, Inode: wino}, hints[1]} if _, err := ReplicatorOnly(t.Context(), root, held(t), agent, wrong); !errors.Is(err, ErrForeignHolder) { t.Fatalf("got %v", err) } }) t.Run("a hint never widens to another process", func(t *testing.T) { other := filepath.Join(root, "30", "task", "30", "fd", "4") if err := os.Symlink(db, other); err != nil { t.Fatal(err) } defer os.Remove(other) extra := append(slices.Clone(hints), DescriptorHint{FD: 4, Device: dev, Inode: ino}) if _, err := ReplicatorOnly(t.Context(), root, held(t), agent, extra); !errors.Is(err, ErrForeignHolder) { t.Fatalf("got %v", err) } }) t.Run("a hinted descriptor root does not see is ignored", func(t *testing.T) { extra := append(slices.Clone(hints), DescriptorHint{FD: 99, Device: dev, Inode: ino}) got, err := ReplicatorOnly(t.Context(), root, held(t), agent, extra) if err != nil || len(got) != 2 { t.Fatalf("%+v %v", got, err) } }) t.Run("the agent's pid now names another process", func(t *testing.T) { writeStat(t, root, 20, "start-reused") defer writeStat(t, root, 20, "start-20") if _, err := ReplicatorOnly(t.Context(), root, held(t), agent, hints); !errors.Is(err, ErrForeignHolder) { t.Fatalf("got %v", err) } }) t.Run("a name-only match has no identity to exempt", func(t *testing.T) { in := []Descriptor{{PID: 20, TID: 20, FD: 7}} if _, err := ReplicatorOnly(t.Context(), root, in, agent, []DescriptorHint{{FD: 7}}); !errors.Is(err, ErrForeignHolder) { t.Fatalf("got %v", err) } }) t.Run("a thread's table", func(t *testing.T) { thread := filepath.Join(root, "20", "task", "21", "fd") if err := os.MkdirAll(thread, 0o700); err != nil { t.Fatal(err) } defer os.RemoveAll(filepath.Join(root, "20", "task", "21")) if err := os.Symlink(db, filepath.Join(thread, "7")); err != nil { t.Fatal(err) } prior := sameFDTable defer func() { sameFDTable = prior }() for _, tc := range []struct { name string shared bool err error want error }{ {"shares the leader's", true, nil, nil}, {"unshared", false, nil, ErrForeignHolder}, {"cannot be compared", false, syscall.EPERM, ErrIncomplete}, } { t.Run(tc.name, func(t *testing.T) { sameFDTable = func(string, int, int) (bool, error) { return tc.shared, tc.err } _, err := ReplicatorOnly(t.Context(), root, held(t), agent, hints) if !errors.Is(err, tc.want) { t.Fatalf("got %v want %v", err, tc.want) } }) } }) t.Run("invalid agent", func(t *testing.T) { if _, err := ReplicatorOnly(t.Context(), root, held(t), Agent{}, hints); !errors.Is(err, ErrInvalidInput) { t.Fatalf("got %v", err) } }) t.Run("nothing held needs no agent proof", func(t *testing.T) { got, err := ReplicatorOnly(t.Context(), root, nil, Agent{PID: 77, Start: "gone"}, nil) if err != nil || len(got) != 0 { t.Fatalf("%+v %v", got, err) } }) } // FDHolders keeps its result and strictness after the refactor onto // Holders: the deleted-name match still counts. func TestFDHoldersStillMatchesNamesAndIdentities(t *testing.T) { root, db := procFixture(t) got, err := FDHolders(root, dbPathsOf(db)) if err != nil || !slices.Equal(got, []int{3, 20}) { t.Fatalf("%v %v", got, err) } }