carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package safefile
2
3import (
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.
18func 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
33func 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
46func 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
54func 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
59func reapTree(dir *os.File, cutoff time.Time, depth int) error {
60 remaining := 100000
61 return reapTreeBudget(dir, cutoff, depth, &remaining)
62}
63
64func 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
116func reapOlder(st *unix.Stat_t, cutoff time.Time) bool {
117 return time.Unix(st.Mtim.Sec, st.Mtim.Nsec).Before(cutoff)
118}
119
120func reapSameEntry(parent int, name string, pinned *unix.Stat_t) bool {
121 var current unix.Stat_t
122 return unix.Fstatat(parent, name, &current, 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
128func 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