package safefile import ( "errors" "fmt" "io" "os" "strings" "time" "golang.org/x/sys/unix" ) // Reap removes abandoned Linux writer staging directories below dir. Traversal // stays on the opened filesystem and never follows symlinks. Only private // directories owned by this euid, older than an hour, and containing at most // the writer's single regular data file are eligible. Missing roots are harmless. func Reap(dir string) error { fd, err := openDir(dir, false, 0) if errors.Is(err, unix.ENOENT) { return nil } if err != nil { return err } err = reapTree(os.NewFile(uintptr(fd), dir), time.Now().Add(-time.Hour), 0) if err != nil { return &os.PathError{Op: "reap staging", Path: dir, Err: err} } return nil } func reapStageName(name string) bool { const prefix = ".carlos-write-" if !strings.HasPrefix(name, prefix) || len(name) != len(prefix)+32 { return false } for _, c := range name[len(prefix):] { if !(c >= '0' && c <= '9' || c >= 'a' && c <= 'f') { return false } } return true } func reapOpenAt(dir int, name string, flags uint64) (int, error) { return unix.Openat2(dir, name, &unix.OpenHow{ Flags: flags | unix.O_CLOEXEC | unix.O_NOFOLLOW, Resolve: unix.RESOLVE_BENEATH | unix.RESOLVE_NO_SYMLINKS | unix.RESOLVE_NO_XDEV, }) } func reapSkip(err error) bool { return errors.Is(err, unix.ENOENT) || errors.Is(err, unix.ELOOP) || errors.Is(err, unix.EXDEV) || errors.Is(err, unix.ENOTDIR) } func reapTree(dir *os.File, cutoff time.Time, depth int) error { remaining := 100000 return reapTreeBudget(dir, cutoff, depth, &remaining) } func reapTreeBudget(dir *os.File, cutoff time.Time, depth int, remaining *int) error { defer dir.Close() if depth > 128 { return fmt.Errorf("staging traversal exceeds 128 directories") } var failures error for { entries, err := dir.ReadDir(128) if err != nil && err != io.EOF { return errors.Join(failures, err) } for _, entry := range entries { if *remaining <= 0 { return errors.Join(failures, fmt.Errorf("staging traversal exceeds 100000 entries")) } *remaining -= 1 // Type is only a fast rejection; openat2 validates the live entry. if entry.Type() != 0 && !entry.IsDir() { continue } name := entry.Name() if strings.HasPrefix(name, ".carlos-write-") && !reapStageName(name) { continue } fd, e := reapOpenAt(int(dir.Fd()), name, unix.O_RDONLY|unix.O_DIRECTORY) if reapSkip(e) { continue } if e != nil { failures = errors.Join(failures, e) continue } child := os.NewFile(uintptr(fd), name) if reapStageName(name) { e = reapStage(int(dir.Fd()), name, child, cutoff) child.Close() } else { e = reapTreeBudget(child, cutoff, depth+1, remaining) } if e != nil { failures = errors.Join(failures, e) if *remaining <= 0 { return failures } } } if err == io.EOF { return failures } } } func reapOlder(st *unix.Stat_t, cutoff time.Time) bool { return time.Unix(st.Mtim.Sec, st.Mtim.Nsec).Before(cutoff) } func reapSameEntry(parent int, name string, pinned *unix.Stat_t) bool { var current unix.Stat_t return unix.Fstatat(parent, name, ¤t, unix.AT_SYMLINK_NOFOLLOW) == nil && current.Dev == pinned.Dev && current.Ino == pinned.Ino && current.Mode == pinned.Mode && current.Uid == pinned.Uid && current.Nlink == pinned.Nlink } func reapStage(parent int, name string, dir *os.File, cutoff time.Time) error { fd := int(dir.Fd()) var stage unix.Stat_t if err := unix.Fstat(fd, &stage); err != nil { return err } if stage.Uid != uint32(os.Geteuid()) || stage.Mode&0o777 != 0o700 || !reapOlder(&stage, cutoff) { return nil } entries, err := dir.ReadDir(2) if err != nil && err != io.EOF { return err } if len(entries) > 1 || len(entries) == 1 && entries[0].Name() != "data" { return nil } if len(entries) == 1 { data, err := reapOpenAt(fd, "data", unix.O_PATH) if reapSkip(err) { return nil } if err != nil { return err } defer unix.Close(data) var st unix.Stat_t if err := unix.Fstat(data, &st); err != nil { return err } if st.Mode&unix.S_IFMT != unix.S_IFREG || st.Nlink != 1 || st.Uid != uint32(os.Geteuid()) || !reapOlder(&st, cutoff) || !reapSameEntry(parent, name, &stage) || !reapSameEntry(fd, "data", &st) { return nil } if err := unix.Unlinkat(fd, "data", 0); err != nil && !errors.Is(err, unix.ENOENT) { return err } } // Unlinkat never follows a replacement symlink. Recheck the parent entry // before removing the empty directory, keeping all content deletion anchored. // A substitution after this check can remove a different empty directory. // That accepted race cannot follow a symlink or remove nonempty replacements. if !reapSameEntry(parent, name, &stage) { return nil } err = unix.Unlinkat(parent, name, unix.AT_REMOVEDIR) if errors.Is(err, unix.ENOENT) || errors.Is(err, unix.ENOTEMPTY) || errors.Is(err, unix.ENOTDIR) { return nil } return err }