//go:build linux package stopevidence import ( "context" "errors" "fmt" "os" "path/filepath" "sort" "strconv" "strings" "syscall" ) type fdOps struct { readDir func(string) ([]os.DirEntry, error) readlink func(string) (string, error) stat func(string) (os.FileInfo, error) lstat func(string) (os.FileInfo, error) } func realFDOps() fdOps { return fdOps{os.ReadDir, os.Readlink, os.Stat, os.Lstat} } func FDHolders(procRoot string, paths []string) ([]int, error) { return fdHolders(context.Background(), procRoot, paths, realFDOps()) } func fdHolders(ctx context.Context, procRoot string, paths []string, ops fdOps) ([]int, error) { if len(paths) == 0 { return nil, ErrInvalidInput } held, err := fdDescriptors(ctx, procRoot, paths, nil, ops) if err != nil { return nil, err } var holders []int for _, d := range held { if len(holders) == 0 || holders[len(holders)-1] != d.PID { holders = append(holders, d.PID) } } return holders, nil } // fdDescriptors is the complete descriptor scan: every task's table of // every process under procRoot, each descriptor matched by name against // paths or by device and inode against paths' files and files. A match // carries the identity stat read through the descriptor; a name match whose // file cannot be reached keeps a zero identity, which no hint exempts. Any // unreadable entry fails the whole scan. Results are ordered by process, // task and descriptor. func fdDescriptors(ctx context.Context, procRoot string, paths []string, files []FileIdentity, ops fdOps) ([]Descriptor, error) { return fdDescriptorsCovered(ctx, procRoot, paths, nil, files, ops) } func fdDescriptorsCovered(ctx context.Context, procRoot string, paths, prefixes []string, files []FileIdentity, ops fdOps) ([]Descriptor, error) { if ctx == nil || !filepath.IsAbs(procRoot) || len(paths)+len(prefixes)+len(files) == 0 { return nil, ErrInvalidInput } for _, prefix := range prefixes { if !filepath.IsAbs(prefix) || filepath.Clean(prefix) != prefix || prefix == string(os.PathSeparator) { return nil, ErrInvalidInput } } wanted := make(map[string]bool, len(paths)) for _, path := range paths { if !filepath.IsAbs(path) { return nil, ErrInvalidInput } wanted[filepath.Clean(path)] = true } // The same file reached by another name (a hard link, another mount of // the file system) is the same inode: match on device and inode too. inodes := map[fileID]bool{} for _, f := range files { if f.Device == 0 || f.Inode == 0 { return nil, ErrInvalidInput } inodes[fileID{dev: f.Device, ino: f.Inode}] = true } for path := range wanted { fi, err := ops.stat(path) if errors.Is(err, os.ErrNotExist) { continue } if err != nil { return nil, fmt.Errorf("%w: %s: %w", ErrIncomplete, path, err) } if id, ok := idOf(fi); ok { inodes[id] = true } else { return nil, fmt.Errorf("%w: %s: no inode", ErrIncomplete, path) } } if err := ctx.Err(); err != nil { return nil, err } entries, err := ops.readDir(procRoot) if err != nil { return nil, fmt.Errorf("%w: %w", ErrIncomplete, err) } var held []Descriptor for _, entry := range entries { if err := ctx.Err(); err != nil { return nil, err } pid, err := strconv.Atoi(entry.Name()) if err != nil || pid <= 0 { continue } pidDir := filepath.Join(procRoot, entry.Name()) tasks, err := readProcDir(filepath.Join(pidDir, "task"), pidDir, ops) if err != nil { return nil, fmt.Errorf("%w: pid %d: %w", ErrIncomplete, pid, err) } for _, task := range tasks { if err := ctx.Err(); err != nil { return nil, err } tid, err := strconv.Atoi(task.Name()) if err != nil || tid <= 0 { continue } taskDir := filepath.Join(pidDir, "task", task.Name()) found, err := taskDescriptorsCovered(ctx, taskDir, pid, tid, wanted, prefixes, inodes, ops) if err != nil { return nil, err } held = append(held, found...) } } if err := ctx.Err(); err != nil { return nil, err } sort.Slice(held, func(i, j int) bool { a, b := held[i], held[j] if a.PID != b.PID { return a.PID < b.PID } if a.TID != b.TID { return a.TID < b.TID } return a.FD < b.FD }) return held, nil } // taskDescriptors reads one task's descriptor table. func taskDescriptors(ctx context.Context, taskDir string, pid, tid int, wanted map[string]bool, inodes map[fileID]bool, ops fdOps) ([]Descriptor, error) { return taskDescriptorsCovered(ctx, taskDir, pid, tid, wanted, nil, inodes, ops) } func taskDescriptorsCovered(ctx context.Context, taskDir string, pid, tid int, wanted map[string]bool, prefixes []string, inodes map[fileID]bool, ops fdOps) ([]Descriptor, error) { fdDir := filepath.Join(taskDir, "fd") fds, err := readProcDir(fdDir, taskDir, ops) if err != nil { return nil, fmt.Errorf("%w: pid %d tid %d: %w", ErrIncomplete, pid, tid, err) } var held []Descriptor for _, fd := range fds { if err := ctx.Err(); err != nil { return nil, err } n, err := strconv.Atoi(fd.Name()) if err != nil || n < 0 { continue } fdPath := filepath.Join(fdDir, fd.Name()) target, err := ops.readlink(fdPath) if procGone(err) { continue } if err != nil { return nil, fmt.Errorf("%w: pid %d tid %d fd %s: %w", ErrIncomplete, pid, tid, fd.Name(), err) } named := filepath.IsAbs(target) && (wanted[filepath.Clean(target)] || wanted[filepath.Clean(strings.TrimSuffix(target, " (deleted)"))]) if filepath.IsAbs(target) && !named { name := filepath.Clean(strings.TrimSuffix(target, " (deleted)")) for _, prefix := range prefixes { if name == prefix || strings.HasPrefix(name, prefix+string(os.PathSeparator)) { named = true break } } } if !named && (len(inodes) == 0 || !filepath.IsAbs(target)) { continue // sockets, pipes and anon inodes name no file } // Stat through the fd link reaches the open file itself, whatever // name it was opened by. An unreadable entry fails the whole scan. fi, err := ops.stat(fdPath) if procGone(err) { // Closed only if the entry itself is gone. An entry that still // exists but cannot be followed is unreadable, not closed. if _, lerr := ops.lstat(fdPath); procGone(lerr) { continue } else if lerr == nil { err = fmt.Errorf("fd entry exists but its file cannot be reached: %w", err) } else { err = lerr } } if named && err != nil { // The name alone is the match; its identity stays unknown. held = append(held, Descriptor{PID: pid, TID: tid, FD: n}) continue } if err != nil { return nil, fmt.Errorf("%w: pid %d tid %d fd %s: %w", ErrIncomplete, pid, tid, fd.Name(), err) } id, ok := idOf(fi) if !ok { if named { held = append(held, Descriptor{PID: pid, TID: tid, FD: n}) continue } return nil, fmt.Errorf("%w: pid %d tid %d fd %s: no inode", ErrIncomplete, pid, tid, fd.Name()) } if named || inodes[id] { held = append(held, Descriptor{PID: pid, TID: tid, FD: n, Device: id.dev, Inode: id.ino}) } } return held, nil } func scanFDs(ctx context.Context, root string, paths []string) ([]int, error) { return fdHolders(ctx, root, paths, realFDOps()) } func procGone(err error) bool { return errors.Is(err, os.ErrNotExist) || errors.Is(err, syscall.ESRCH) } func readProcDir(path, owner string, ops fdOps) ([]os.DirEntry, error) { entries, err := ops.readDir(path) if procGone(err) { if _, ownerErr := ops.stat(owner); procGone(ownerErr) { return nil, nil } else if ownerErr != nil { return nil, ownerErr } } return entries, err } type fileID struct{ dev, ino uint64 } func idOf(fi os.FileInfo) (fileID, bool) { st, ok := fi.Sys().(*syscall.Stat_t) if !ok { return fileID{}, false } return fileID{dev: uint64(st.Dev), ino: st.Ino}, true }