| 1 | package safefile |
| 2 | |
| 3 | import ( |
| 4 | "errors" |
| 5 | "fmt" |
| 6 | "io" |
| 7 | "os" |
| 8 | "strings" |
| 9 | "time" |
| 10 | |
| 11 | "golang.org/x/sys/unix" |
| 12 | ) |
| 13 | |
| 14 | // Reap removes abandoned Linux writer staging directories below dir. Traversal |
| 15 | // stays on the opened filesystem and never follows symlinks. Only private |
| 16 | // directories owned by this euid, older than an hour, and containing at most |
| 17 | // the writer's single regular data file are eligible. Missing roots are harmless. |
| 18 | func Reap(dir string) error { |
| 19 | fd, err := openDir(dir, false, 0) |
| 20 | if errors.Is(err, unix.ENOENT) { |
| 21 | return nil |
| 22 | } |
| 23 | if err != nil { |
| 24 | return err |
| 25 | } |
| 26 | err = reapTree(os.NewFile(uintptr(fd), dir), time.Now().Add(-time.Hour), 0) |
| 27 | if err != nil { |
| 28 | return &os.PathError{Op: "reap staging", Path: dir, Err: err} |
| 29 | } |
| 30 | return nil |
| 31 | } |
| 32 | |
| 33 | func reapStageName(name string) bool { |
| 34 | const prefix = ".carlos-write-" |
| 35 | if !strings.HasPrefix(name, prefix) || len(name) != len(prefix)+32 { |
| 36 | return false |
| 37 | } |
| 38 | for _, c := range name[len(prefix):] { |
| 39 | if !(c >= '0' && c <= '9' || c >= 'a' && c <= 'f') { |
| 40 | return false |
| 41 | } |
| 42 | } |
| 43 | return true |
| 44 | } |
| 45 | |
| 46 | func reapOpenAt(dir int, name string, flags uint64) (int, error) { |
| 47 | return unix.Openat2(dir, name, &unix.OpenHow{ |
| 48 | Flags: flags | unix.O_CLOEXEC | unix.O_NOFOLLOW, |
| 49 | Resolve: unix.RESOLVE_BENEATH | unix.RESOLVE_NO_SYMLINKS | |
| 50 | unix.RESOLVE_NO_XDEV, |
| 51 | }) |
| 52 | } |
| 53 | |
| 54 | func reapSkip(err error) bool { |
| 55 | return errors.Is(err, unix.ENOENT) || errors.Is(err, unix.ELOOP) || |
| 56 | errors.Is(err, unix.EXDEV) || errors.Is(err, unix.ENOTDIR) |
| 57 | } |
| 58 | |
| 59 | func reapTree(dir *os.File, cutoff time.Time, depth int) error { |
| 60 | remaining := 100000 |
| 61 | return reapTreeBudget(dir, cutoff, depth, &remaining) |
| 62 | } |
| 63 | |
| 64 | func reapTreeBudget(dir *os.File, cutoff time.Time, depth int, remaining *int) error { |
| 65 | defer dir.Close() |
| 66 | if depth > 128 { |
| 67 | return fmt.Errorf("staging traversal exceeds 128 directories") |
| 68 | } |
| 69 | var failures error |
| 70 | for { |
| 71 | entries, err := dir.ReadDir(128) |
| 72 | if err != nil && err != io.EOF { |
| 73 | return errors.Join(failures, err) |
| 74 | } |
| 75 | for _, entry := range entries { |
| 76 | if *remaining <= 0 { |
| 77 | return errors.Join(failures, fmt.Errorf("staging traversal exceeds 100000 entries")) |
| 78 | } |
| 79 | *remaining -= 1 |
| 80 | // Type is only a fast rejection; openat2 validates the live entry. |
| 81 | if entry.Type() != 0 && !entry.IsDir() { |
| 82 | continue |
| 83 | } |
| 84 | name := entry.Name() |
| 85 | if strings.HasPrefix(name, ".carlos-write-") && !reapStageName(name) { |
| 86 | continue |
| 87 | } |
| 88 | fd, e := reapOpenAt(int(dir.Fd()), name, unix.O_RDONLY|unix.O_DIRECTORY) |
| 89 | if reapSkip(e) { |
| 90 | continue |
| 91 | } |
| 92 | if e != nil { |
| 93 | failures = errors.Join(failures, e) |
| 94 | continue |
| 95 | } |
| 96 | child := os.NewFile(uintptr(fd), name) |
| 97 | if reapStageName(name) { |
| 98 | e = reapStage(int(dir.Fd()), name, child, cutoff) |
| 99 | child.Close() |
| 100 | } else { |
| 101 | e = reapTreeBudget(child, cutoff, depth+1, remaining) |
| 102 | } |
| 103 | if e != nil { |
| 104 | failures = errors.Join(failures, e) |
| 105 | if *remaining <= 0 { |
| 106 | return failures |
| 107 | } |
| 108 | } |
| 109 | } |
| 110 | if err == io.EOF { |
| 111 | return failures |
| 112 | } |
| 113 | } |
| 114 | } |
| 115 | |
| 116 | func reapOlder(st *unix.Stat_t, cutoff time.Time) bool { |
| 117 | return time.Unix(st.Mtim.Sec, st.Mtim.Nsec).Before(cutoff) |
| 118 | } |
| 119 | |
| 120 | func reapSameEntry(parent int, name string, pinned *unix.Stat_t) bool { |
| 121 | var current unix.Stat_t |
| 122 | return unix.Fstatat(parent, name, ¤t, unix.AT_SYMLINK_NOFOLLOW) == nil && |
| 123 | current.Dev == pinned.Dev && current.Ino == pinned.Ino && |
| 124 | current.Mode == pinned.Mode && current.Uid == pinned.Uid && |
| 125 | current.Nlink == pinned.Nlink |
| 126 | } |
| 127 | |
| 128 | func reapStage(parent int, name string, dir *os.File, cutoff time.Time) error { |
| 129 | fd := int(dir.Fd()) |
| 130 | var stage unix.Stat_t |
| 131 | if err := unix.Fstat(fd, &stage); err != nil { |
| 132 | return err |
| 133 | } |
| 134 | if stage.Uid != uint32(os.Geteuid()) || stage.Mode&0o777 != 0o700 || |
| 135 | !reapOlder(&stage, cutoff) { |
| 136 | return nil |
| 137 | } |
| 138 | entries, err := dir.ReadDir(2) |
| 139 | if err != nil && err != io.EOF { |
| 140 | return err |
| 141 | } |
| 142 | if len(entries) > 1 || len(entries) == 1 && entries[0].Name() != "data" { |
| 143 | return nil |
| 144 | } |
| 145 | if len(entries) == 1 { |
| 146 | data, err := reapOpenAt(fd, "data", unix.O_PATH) |
| 147 | if reapSkip(err) { |
| 148 | return nil |
| 149 | } |
| 150 | if err != nil { |
| 151 | return err |
| 152 | } |
| 153 | defer unix.Close(data) |
| 154 | var st unix.Stat_t |
| 155 | if err := unix.Fstat(data, &st); err != nil { |
| 156 | return err |
| 157 | } |
| 158 | if st.Mode&unix.S_IFMT != unix.S_IFREG || st.Nlink != 1 || |
| 159 | st.Uid != uint32(os.Geteuid()) || !reapOlder(&st, cutoff) || |
| 160 | !reapSameEntry(parent, name, &stage) || !reapSameEntry(fd, "data", &st) { |
| 161 | return nil |
| 162 | } |
| 163 | if err := unix.Unlinkat(fd, "data", 0); err != nil && !errors.Is(err, unix.ENOENT) { |
| 164 | return err |
| 165 | } |
| 166 | } |
| 167 | // Unlinkat never follows a replacement symlink. Recheck the parent entry |
| 168 | // before removing the empty directory, keeping all content deletion anchored. |
| 169 | // A substitution after this check can remove a different empty directory. |
| 170 | // That accepted race cannot follow a symlink or remove nonempty replacements. |
| 171 | if !reapSameEntry(parent, name, &stage) { |
| 172 | return nil |
| 173 | } |
| 174 | err = unix.Unlinkat(parent, name, unix.AT_REMOVEDIR) |
| 175 | if errors.Is(err, unix.ENOENT) || errors.Is(err, unix.ENOTEMPTY) || errors.Is(err, unix.ENOTDIR) { |
| 176 | return nil |
| 177 | } |
| 178 | return err |
| 179 | } |
| 180 | |