Add bounded concurrent scanner
Single-writer controller with a select loop that offers jobs and accepts results simultaneously, so bounded channels cannot deadlock. statx/fstatat relative to the directory fd on Linux/macOS, portable fallback elsewhere. Hard links counted once, symlink loops detected, virtual filesystems skipped, errors classified and surfaced.
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
//go:build unix
|
||||
|
||||
package platform
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func modeFromUnix(m uint32) fs.FileMode {
|
||||
mode := fs.FileMode(m & 0o777)
|
||||
switch m & syscall.S_IFMT {
|
||||
case syscall.S_IFDIR:
|
||||
mode |= fs.ModeDir
|
||||
case syscall.S_IFLNK:
|
||||
mode |= fs.ModeSymlink
|
||||
case syscall.S_IFIFO:
|
||||
mode |= fs.ModeNamedPipe
|
||||
case syscall.S_IFSOCK:
|
||||
mode |= fs.ModeSocket
|
||||
case syscall.S_IFCHR:
|
||||
mode |= fs.ModeDevice | fs.ModeCharDevice
|
||||
case syscall.S_IFBLK:
|
||||
mode |= fs.ModeDevice
|
||||
}
|
||||
if m&syscall.S_ISUID != 0 {
|
||||
mode |= fs.ModeSetuid
|
||||
}
|
||||
if m&syscall.S_ISGID != 0 {
|
||||
mode |= fs.ModeSetgid
|
||||
}
|
||||
if m&syscall.S_ISVTX != 0 {
|
||||
mode |= fs.ModeSticky
|
||||
}
|
||||
return mode
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
// Package platform hides OS-specific filesystem metadata behind a small
|
||||
// common API. Files are split by build tag; nothing else in the program
|
||||
// switches on GOOS.
|
||||
package platform
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"os/user"
|
||||
"strconv"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Meta is the metadata of one directory entry.
|
||||
type Meta struct {
|
||||
Name string
|
||||
Mode fs.FileMode
|
||||
Size int64
|
||||
Alloc int64
|
||||
AllocKnown bool
|
||||
MTime int64 // unix nanoseconds
|
||||
BTime int64 // birth time, 0 if unknown
|
||||
UID, GID uint32
|
||||
Nlink uint32
|
||||
Dev, Ino uint64
|
||||
HasIno bool // Dev/Ino are meaningful
|
||||
Err error // stat failed; only Name is valid
|
||||
}
|
||||
|
||||
// ChunkFunc receives successive batches of entries. The slice is owned by
|
||||
// the callee. Returning an error stops the read.
|
||||
type ChunkFunc func([]Meta) error
|
||||
|
||||
// Lstat returns metadata for path without following a final symlink.
|
||||
func Lstat(path string) (Meta, error) { return statPath(path, false) }
|
||||
|
||||
// Stat returns metadata for path, following symlinks.
|
||||
func Stat(path string) (Meta, error) { return statPath(path, true) }
|
||||
|
||||
var (
|
||||
nameMu sync.Mutex
|
||||
userNames = map[uint32]string{}
|
||||
groupNames = map[uint32]string{}
|
||||
)
|
||||
|
||||
// UserName resolves a uid to a name (cached; falls back to the number).
|
||||
func UserName(uid uint32) string {
|
||||
return lookupName(userNames, uid, func(s string) (string, error) {
|
||||
u, err := user.LookupId(s)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return u.Username, nil
|
||||
})
|
||||
}
|
||||
|
||||
// GroupName resolves a gid to a name (cached; falls back to the number).
|
||||
func GroupName(gid uint32) string {
|
||||
return lookupName(groupNames, gid, func(s string) (string, error) {
|
||||
g, err := user.LookupGroupId(s)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return g.Name, nil
|
||||
})
|
||||
}
|
||||
|
||||
func lookupName(cache map[uint32]string, id uint32, look func(string) (string, error)) string {
|
||||
nameMu.Lock()
|
||||
defer nameMu.Unlock()
|
||||
if n, ok := cache[id]; ok {
|
||||
return n
|
||||
}
|
||||
s := strconv.FormatUint(uint64(id), 10)
|
||||
if n, err := look(s); err == nil {
|
||||
s = n
|
||||
}
|
||||
cache[id] = s
|
||||
return s
|
||||
}
|
||||
|
||||
// fromFileInfo fills the portable fields of Meta.
|
||||
func fromFileInfo(fi fs.FileInfo) Meta {
|
||||
return Meta{
|
||||
Name: fi.Name(),
|
||||
Mode: fi.Mode(),
|
||||
Size: fi.Size(),
|
||||
Alloc: fi.Size(),
|
||||
MTime: fi.ModTime().UnixNano(),
|
||||
Nlink: 1,
|
||||
}
|
||||
}
|
||||
|
||||
// readDirGeneric is the portable fallback: os.ReadDir in chunks plus Lstat
|
||||
// per entry by path.
|
||||
func readDirGeneric(path string, chunk int, fn ChunkFunc, stat func(string) (Meta, error)) error {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
for {
|
||||
des, err := f.ReadDir(chunk)
|
||||
if len(des) > 0 {
|
||||
out := make([]Meta, len(des))
|
||||
for i, de := range des {
|
||||
m, serr := stat(joinPath(path, de.Name()))
|
||||
if serr != nil {
|
||||
m = Meta{Err: serr}
|
||||
}
|
||||
m.Name = de.Name()
|
||||
out[i] = m
|
||||
}
|
||||
if ferr := fn(out); ferr != nil {
|
||||
return ferr
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func joinPath(dir, name string) string {
|
||||
if len(dir) > 0 && os.IsPathSeparator(dir[len(dir)-1]) {
|
||||
return dir + name
|
||||
}
|
||||
return dir + string(os.PathSeparator) + name
|
||||
}
|
||||
|
||||
// JoinPath joins a directory and an entry name without cleaning.
|
||||
func JoinPath(dir, name string) string { return joinPath(dir, name) }
|
||||
|
||||
func baseName(path string) string {
|
||||
i := len(path) - 1
|
||||
for i > 0 && os.IsPathSeparator(path[i]) {
|
||||
i--
|
||||
}
|
||||
path = path[:i+1]
|
||||
for j := len(path) - 1; j >= 0; j-- {
|
||||
if os.IsPathSeparator(path[j]) {
|
||||
return path[j+1:]
|
||||
}
|
||||
}
|
||||
return path
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
//go:build darwin || freebsd
|
||||
|
||||
package platform
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// ReadDir streams the entries of a directory in chunks, stat-ing each entry
|
||||
// relative to the directory fd.
|
||||
func ReadDir(path string, chunk int, fn ChunkFunc) error {
|
||||
fd, err := unix.Open(path, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC, 0)
|
||||
if err != nil {
|
||||
return &os.PathError{Op: "open", Path: path, Err: err}
|
||||
}
|
||||
f := os.NewFile(uintptr(fd), path)
|
||||
defer f.Close()
|
||||
for {
|
||||
des, err := f.ReadDir(chunk)
|
||||
if len(des) > 0 {
|
||||
out := make([]Meta, len(des))
|
||||
for i, de := range des {
|
||||
var st unix.Stat_t
|
||||
if serr := unix.Fstatat(fd, de.Name(), &st, unix.AT_SYMLINK_NOFOLLOW); serr != nil {
|
||||
out[i] = Meta{Name: de.Name(), Err: &os.PathError{Op: "stat", Path: de.Name(), Err: serr}}
|
||||
continue
|
||||
}
|
||||
out[i] = fromStat(&st)
|
||||
out[i].Name = de.Name()
|
||||
}
|
||||
if ferr := fn(out); ferr != nil {
|
||||
return ferr
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func fromStat(st *unix.Stat_t) Meta {
|
||||
return Meta{
|
||||
Mode: modeFromUnix(uint32(st.Mode)),
|
||||
Size: st.Size,
|
||||
Alloc: st.Blocks * 512,
|
||||
AllocKnown: true,
|
||||
MTime: st.Mtim.Nano(),
|
||||
BTime: st.Btim.Nano(),
|
||||
UID: st.Uid,
|
||||
GID: st.Gid,
|
||||
Nlink: uint32(st.Nlink),
|
||||
Dev: uint64(st.Dev),
|
||||
Ino: uint64(st.Ino),
|
||||
HasIno: true,
|
||||
}
|
||||
}
|
||||
|
||||
func statPath(path string, follow bool) (Meta, error) {
|
||||
var st unix.Stat_t
|
||||
var err error
|
||||
if follow {
|
||||
err = unix.Stat(path, &st)
|
||||
} else {
|
||||
err = unix.Lstat(path, &st)
|
||||
}
|
||||
if err != nil {
|
||||
return Meta{}, &os.PathError{Op: "stat", Path: path, Err: err}
|
||||
}
|
||||
m := fromStat(&st)
|
||||
m.Name = baseName(path)
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// IsVirtualFS is Linux-specific; elsewhere nothing is skipped.
|
||||
func IsVirtualFS(string) bool { return false }
|
||||
@@ -0,0 +1,138 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"sync/atomic"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// noStatx is set if the kernel lacks statx (pre-4.11); we fall back to fstatat.
|
||||
var noStatx atomic.Bool
|
||||
|
||||
const statxMask = unix.STATX_BASIC_STATS | unix.STATX_BTIME
|
||||
|
||||
// ReadDir streams the entries of a directory in chunks, stat-ing each entry
|
||||
// relative to the directory fd (no repeated path resolution).
|
||||
func ReadDir(path string, chunk int, fn ChunkFunc) error {
|
||||
fd, err := unix.Open(path, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC, 0)
|
||||
if err != nil {
|
||||
return &os.PathError{Op: "open", Path: path, Err: err}
|
||||
}
|
||||
f := os.NewFile(uintptr(fd), path)
|
||||
defer f.Close()
|
||||
for {
|
||||
des, err := f.ReadDir(chunk)
|
||||
if len(des) > 0 {
|
||||
out := make([]Meta, len(des))
|
||||
for i, de := range des {
|
||||
m, serr := statAt(fd, de.Name(), unix.AT_SYMLINK_NOFOLLOW)
|
||||
if serr != nil {
|
||||
m = Meta{Err: &os.PathError{Op: "stat", Path: de.Name(), Err: serr}}
|
||||
}
|
||||
m.Name = de.Name()
|
||||
out[i] = m
|
||||
}
|
||||
if ferr := fn(out); ferr != nil {
|
||||
return ferr
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func statAt(dirfd int, name string, flags int) (Meta, error) {
|
||||
if !noStatx.Load() {
|
||||
var st unix.Statx_t
|
||||
err := unix.Statx(dirfd, name, flags|unix.AT_STATX_DONT_SYNC, statxMask, &st)
|
||||
if err == nil {
|
||||
return fromStatx(&st), nil
|
||||
}
|
||||
if !errors.Is(err, unix.ENOSYS) {
|
||||
return Meta{}, err
|
||||
}
|
||||
noStatx.Store(true)
|
||||
}
|
||||
var st unix.Stat_t
|
||||
if err := unix.Fstatat(dirfd, name, &st, flags); err != nil {
|
||||
return Meta{}, err
|
||||
}
|
||||
return Meta{
|
||||
Mode: modeFromUnix(st.Mode), Size: st.Size, Alloc: st.Blocks * 512, AllocKnown: true,
|
||||
MTime: st.Mtim.Nano(), UID: st.Uid, GID: st.Gid, Nlink: uint32(st.Nlink),
|
||||
Dev: st.Dev, Ino: st.Ino, HasIno: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func fromStatx(st *unix.Statx_t) Meta {
|
||||
m := Meta{
|
||||
Mode: modeFromUnix(uint32(st.Mode)),
|
||||
Size: int64(st.Size),
|
||||
Alloc: int64(st.Blocks) * 512,
|
||||
AllocKnown: true,
|
||||
MTime: st.Mtime.Sec*1e9 + int64(st.Mtime.Nsec),
|
||||
UID: st.Uid,
|
||||
GID: st.Gid,
|
||||
Nlink: st.Nlink,
|
||||
Dev: unix.Mkdev(st.Dev_major, st.Dev_minor),
|
||||
Ino: st.Ino,
|
||||
HasIno: true,
|
||||
}
|
||||
if st.Mask&unix.STATX_BTIME != 0 {
|
||||
m.BTime = st.Btime.Sec*1e9 + int64(st.Btime.Nsec)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func statPath(path string, follow bool) (Meta, error) {
|
||||
flags := unix.AT_SYMLINK_NOFOLLOW
|
||||
if follow {
|
||||
flags = 0
|
||||
}
|
||||
m, err := statAt(unix.AT_FDCWD, path, flags)
|
||||
if err != nil {
|
||||
return Meta{}, &os.PathError{Op: "stat", Path: path, Err: err}
|
||||
}
|
||||
m.Name = baseName(path)
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Linux pseudo-filesystems whose contents are not disk usage.
|
||||
var virtualFS = map[int64]bool{
|
||||
0x9fa0: true, // proc
|
||||
0x62656572: true, // sysfs
|
||||
0x1cd1: true, // devpts
|
||||
0x27e0eb: true, // cgroup
|
||||
0x63677270: true, // cgroup2
|
||||
0x64626720: true, // debugfs
|
||||
0x74726163: true, // tracefs
|
||||
0x73636673: true, // securityfs
|
||||
0x6165676c: true, // pstore
|
||||
0xcafe4a11: true, // bpf
|
||||
0x62656570: true, // configfs
|
||||
0x65735543: true, // fusectl
|
||||
0x19800202: true, // mqueue
|
||||
0xde5e81e4: true, // efivarfs
|
||||
0x42494e4d: true, // binfmt_misc
|
||||
0xf97cff8c: true, // selinuxfs
|
||||
0x958458f6: true, // hugetlbfs
|
||||
0x6e736673: true, // nsfs
|
||||
0x50495045: true, // pipefs
|
||||
}
|
||||
|
||||
// IsVirtualFS reports whether path is the root of a pseudo filesystem
|
||||
// (proc, sysfs, cgroup, …) that should not be scanned as disk usage.
|
||||
func IsVirtualFS(path string) bool {
|
||||
var st unix.Statfs_t
|
||||
if err := unix.Statfs(path, &st); err != nil {
|
||||
return false
|
||||
}
|
||||
return virtualFS[int64(st.Type)]
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
//go:build !linux && !darwin && !freebsd
|
||||
|
||||
package platform
|
||||
|
||||
import "os"
|
||||
|
||||
// ReadDir streams the entries of a directory using the portable os API.
|
||||
// Allocated size, inode identity and link counts are not available here.
|
||||
func ReadDir(path string, chunk int, fn ChunkFunc) error {
|
||||
return readDirGeneric(path, chunk, fn, func(p string) (Meta, error) { return statPath(p, false) })
|
||||
}
|
||||
|
||||
func statPath(path string, follow bool) (Meta, error) {
|
||||
var fi os.FileInfo
|
||||
var err error
|
||||
if follow {
|
||||
fi, err = os.Stat(path)
|
||||
} else {
|
||||
fi, err = os.Lstat(path)
|
||||
}
|
||||
if err != nil {
|
||||
return Meta{}, err
|
||||
}
|
||||
m := fromFileInfo(fi)
|
||||
m.Name = baseName(path)
|
||||
fillSys(&m, fi)
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// IsVirtualFS is Linux-specific; elsewhere nothing is skipped.
|
||||
func IsVirtualFS(string) bool { return false }
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build !windows && !linux && !darwin && !freebsd
|
||||
|
||||
package platform
|
||||
|
||||
import "os"
|
||||
|
||||
func fillSys(*Meta, os.FileInfo) {}
|
||||
@@ -0,0 +1,12 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func fillSys(m *Meta, fi os.FileInfo) {
|
||||
if d, ok := fi.Sys().(*syscall.Win32FileAttributeData); ok {
|
||||
m.BTime = d.CreationTime.Nanoseconds()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user