//go:build linux package stopevidence import ( "context" "errors" "os" "path/filepath" "reflect" "runtime" "strconv" "syscall" "testing" "golang.org/x/sys/unix" ) func procFixture(t *testing.T) (string, string) { t.Helper() root, db := t.TempDir(), filepath.Join(t.TempDir(), "data.db") for pid, targets := range map[string][]string{ "20": {db, db + "-wal", db + "-shm"}, "3": {db + " (deleted)"}, "90": {db + ".other"}, } { dir := filepath.Join(root, pid, "task", pid, "fd") if err := os.MkdirAll(dir, 0700); err != nil { t.Fatal(err) } for n, target := range targets { if err := os.Symlink(target, filepath.Join(dir, strconv.Itoa(n))); err != nil { t.Fatal(err) } } } return root, db } func TestFDHolders(t *testing.T) { root, db := procFixture(t) got, err := FDHolders(root, []string{filepath.Join(filepath.Dir(db), ".", "data.db"), db + "-wal", db + "-shm"}) if err != nil || !reflect.DeepEqual(got, []int{3, 20}) { t.Fatal(got, err) } if _, err := FDHolders(root, []string{"relative.db"}); !errors.Is(err, ErrInvalidInput) { t.Fatal(err) } if _, err := FDHolders(root, nil); !errors.Is(err, ErrInvalidInput) { t.Fatal(err) } if got, err := FDHolders(filepath.Join(root, "missing"), []string{db}); !errors.Is(err, ErrIncomplete) || got != nil { t.Fatal(got, err) } } func TestFDScanRefusesIncompleteReads(t *testing.T) { root, db := procFixture(t) for _, where := range []string{"root", "fd-directory", "readlink"} { t.Run(where, func(t *testing.T) { ops := realFDOps() readDir, readlink := ops.readDir, ops.readlink ops.readDir = func(p string) ([]os.DirEntry, error) { if (where == "root" && p == root) || (where == "fd-directory" && p == filepath.Join(root, "20", "task", "20", "fd")) { return nil, syscall.EACCES } return readDir(p) } ops.readlink = func(p string) (string, error) { if where == "readlink" && p == filepath.Join(root, "20", "task", "20", "fd", "0") { return "", syscall.EACCES } return readlink(p) } got, err := fdHolders(t.Context(), root, []string{db}, ops) if !errors.Is(err, ErrIncomplete) || !errors.Is(err, syscall.EACCES) || got != nil { t.Fatalf("partial success: %v %v", got, err) } }) } ops := realFDOps() readDir := ops.readDir ops.readDir = func(p string) ([]os.DirEntry, error) { if p == filepath.Join(root, "20", "task", "20", "fd") { return nil, os.ErrNotExist } return readDir(p) } if _, err := fdHolders(t.Context(), root, []string{db}, ops); !errors.Is(err, ErrIncomplete) { t.Fatal("live PID with missing fd directory", err) } stat := ops.stat ops.stat = func(p string) (os.FileInfo, error) { if p == filepath.Join(root, "20", "task", "20") { return nil, os.ErrNotExist } return stat(p) } got, err := fdHolders(t.Context(), root, []string{db}, ops) if err != nil || !reflect.DeepEqual(got, []int{3}) { t.Fatal("vanished process", got, err) } ops = realFDOps() readlink := ops.readlink ops.readlink = func(p string) (string, error) { if p == filepath.Join(root, "20", "task", "20", "fd", "0") { return "", os.ErrNotExist } return readlink(p) } if got, err := fdHolders(t.Context(), root, []string{db}, ops); err != nil || !reflect.DeepEqual(got, []int{3}) { t.Fatal("closed fd", got, err) } ctx, cancel := context.WithCancel(context.Background()) cancel() if _, err := fdHolders(ctx, root, []string{db}, realFDOps()); !errors.Is(err, context.Canceled) { t.Fatal(err) } } func TestFDHoldersRealProc(t *testing.T) { f, err := os.CreateTemp(t.TempDir(), "open-db-") if err != nil { t.Fatal(err) } defer f.Close() pid := os.Getpid() // Project the real /proc entry into an otherwise empty scan root. This // proves real descriptors work without requiring access to other UIDs. root := t.TempDir() if err := os.Symlink(filepath.Join("/proc", strconv.Itoa(pid)), filepath.Join(root, strconv.Itoa(pid))); err != nil { t.Fatal(err) } got, err := FDHolders(root, []string{f.Name()}) if err != nil || !reflect.DeepEqual(got, []int{pid}) { t.Fatal(got, err) } got, err = FDHolders("/proc", []string{f.Name()}) if errors.Is(err, ErrIncomplete) { t.Log("whole-machine scan correctly refused; real self descriptor witness passed") return } if err != nil { t.Fatal(err) } for _, holder := range got { if holder == pid { return } } t.Fatal("real /proc scan missed own descriptor", got) } func TestFDHoldersTaskTables(t *testing.T) { root, db := procFixture(t) for _, tid := range []string{"91", "92"} { dir := filepath.Join(root, "90", "task", tid, "fd") if err := os.MkdirAll(dir, 0700); err != nil { t.Fatal(err) } if err := os.Symlink(db, filepath.Join(dir, "4")); err != nil { t.Fatal(err) } } got, err := FDHolders(root, []string{db}) if err != nil || !reflect.DeepEqual(got, []int{3, 20, 90}) { t.Fatalf("task-only holders: %v %v", got, err) } } func TestFDTaskScanErrors(t *testing.T) { root, db := procFixture(t) taskDir := filepath.Join(root, "90", "task", "91") fdDir := filepath.Join(taskDir, "fd") if err := os.MkdirAll(fdDir, 0700); err != nil { t.Fatal(err) } if err := os.Symlink(db, filepath.Join(fdDir, "4")); err != nil { t.Fatal(err) } for _, path := range []string{filepath.Join(root, "90", "task"), fdDir, filepath.Join(fdDir, "4")} { for _, failure := range []error{syscall.EACCES, syscall.ENOENT, syscall.ESRCH} { t.Run(filepath.Base(path)+"/"+failure.Error(), func(t *testing.T) { ops := realFDOps() readDir, readlink, stat := ops.readDir, ops.readlink, ops.stat ops.readDir = func(p string) ([]os.DirEntry, error) { if p == path { return nil, failure } return readDir(p) } ops.readlink = func(p string) (string, error) { if p == path { return "", failure } return readlink(p) } ops.stat = func(p string) (os.FileInfo, error) { if p == taskDir || p == filepath.Join(root, "90") { return nil, failure } return stat(p) } got, err := fdHolders(t.Context(), root, []string{db}, ops) if failure == syscall.EACCES { if got != nil || !errors.Is(err, ErrIncomplete) || !errors.Is(err, failure) { t.Fatalf("partial success: %v %v", got, err) } } else if err != nil || !reflect.DeepEqual(got, []int{3, 20}) { t.Fatalf("vanished task: %v %v", got, err) } }) } } } func TestFDHoldersUnsharedThread(t *testing.T) { path := filepath.Join(t.TempDir(), "thread.db") ready, release, done := make(chan error), make(chan struct{}), make(chan struct{}) leader := false go func() { runtime.LockOSThread() defer close(done) // The goroutine may land on the thread-group leader, whose table is // /proc//fd itself; the leader-invisibility check below only // holds for a non-leader thread. leader = unix.Gettid() == os.Getpid() if err := unix.Unshare(unix.CLONE_FILES); err != nil { ready <- err return } fd, err := unix.Open(path, unix.O_RDWR|unix.O_CREAT|unix.O_CLOEXEC, 0600) if err != nil { ready <- err return } defer unix.Close(fd) ready <- nil <-release }() defer func() { close(release); <-done }() if err := <-ready; err != nil { if errors.Is(err, unix.EPERM) || errors.Is(err, unix.EACCES) || errors.Is(err, unix.ENOSYS) { t.Skipf("unshare unavailable: %v", err) } t.Fatal(err) } pid := strconv.Itoa(os.Getpid()) entries, err := os.ReadDir(filepath.Join("/proc", pid, "fd")) if err != nil { t.Fatal(err) } for _, entry := range entries { target, err := os.Readlink(filepath.Join("/proc", pid, "fd", entry.Name())) if err == nil && target == path && !leader { t.Fatal("private descriptor visible in leader table") } } root := t.TempDir() if err := os.Symlink(filepath.Join("/proc", pid), filepath.Join(root, pid)); err != nil { t.Fatal(err) } got, err := FDHolders(root, []string{path}) if err != nil || !reflect.DeepEqual(got, []int{os.Getpid()}) { t.Fatalf("unshared thread holder: %v %v", got, err) } } // A writer holding the database through another name (a hard link, another // mount of the same file system) holds the same inode; the scan must see it. func TestFDHoldersMatchesTheInodeNotOnlyThePath(t *testing.T) { root, dir := t.TempDir(), t.TempDir() db, alias := filepath.Join(dir, "data.db"), filepath.Join(dir, "alias.db") if err := os.WriteFile(db, []byte("db"), 0600); err != nil { t.Fatal(err) } if err := os.Link(db, alias); err != nil { t.Fatal(err) } fd := filepath.Join(root, "40", "task", "40", "fd") if err := os.MkdirAll(fd, 0700); err != nil { t.Fatal(err) } if err := os.Symlink(alias, filepath.Join(fd, "0")); err != nil { t.Fatal(err) } got, err := FDHolders(root, []string{db, db + "-wal"}) if err != nil || !reflect.DeepEqual(got, []int{40}) { t.Fatalf("hard-linked holder missed: %v %v", got, err) } ops := realFDOps() stat := ops.stat ops.stat = func(p string) (os.FileInfo, error) { if p == filepath.Join(fd, "0") { return nil, syscall.EACCES } return stat(p) } if got, err := fdHolders(t.Context(), root, []string{db}, ops); !errors.Is(err, ErrIncomplete) || got != nil { t.Fatalf("unreadable fd counted as not holding: %v %v", got, err) } ops = realFDOps() ops.stat = func(p string) (os.FileInfo, error) { if p == db { return nil, syscall.EACCES } return stat(p) } if got, err := fdHolders(t.Context(), root, []string{db}, ops); !errors.Is(err, ErrIncomplete) || got != nil { t.Fatalf("unreadable wanted path accepted: %v %v", got, err) } // A live entry whose target cannot be followed is unreadable, not closed; // a vanished entry is closed. for _, gone := range []bool{false, true} { ops = realFDOps() ops.stat = func(p string) (os.FileInfo, error) { if p == filepath.Join(fd, "0") { return nil, os.ErrNotExist } return stat(p) } if gone { lstat := ops.lstat ops.lstat = func(p string) (os.FileInfo, error) { if p == filepath.Join(fd, "0") { return nil, os.ErrNotExist } return lstat(p) } } got, err := fdHolders(t.Context(), root, []string{db}, ops) if gone && (err != nil || len(got) != 0) { t.Fatalf("closed fd: %v %v", got, err) } if !gone && (!errors.Is(err, ErrIncomplete) || got != nil) { t.Fatalf("live entry with an unreachable target counted as closed: %v %v", got, err) } } }