store.go RawHistoryFolder

1package captureallow
2
3import (
4	"context"
5	"errors"
6	"fmt"
7	"io/fs"
8	"os"
9	"path/filepath"
10	"slices"
11	"sync/atomic"
12	"syscall"
13	"time"
14	"unsafe"
15
16	"github.com/bnema/neferwl/internal/adapters/fdwait"
17	"github.com/bnema/zerowrap"
18	"golang.org/x/sys/unix"
19)
20
21// load reads the file at path, which must be trustworthy. owner is the uid
22// that must own the file, and may own its directories besides root.
23// Production passes 0; tests pass their own uid. A missing file gives the
24// built-in policy and an error wrapping fs.ErrNotExist; any other failure gives
25// the closed policy and the error, so a broken or tampered-with file never
26// opens capture.
27//
28// The file is opened without following a symlink and without blocking (a FIFO
29// must not hang the compositor), then checked on the descriptor: a regular
30// file, owned by owner, writable by nobody else. Every directory from / down
31// to the file's own must be a real directory (not a symlink), owned by root or
32// owner, and not group- or other-writable unless sticky. Anything else fails
33// closed. Only a missing file, or a missing directory that holds the file
34// (/etc/neferwl) under checked ancestors, gives the built-in list.
35func load(path string, owner uint32) (*Policy, error) {
36	path = filepath.Clean(path)
37	if !filepath.IsAbs(path) {
38		return Closed(), fmt.Errorf("%s: path is not absolute", path)
39	}
40	missing, err := checkDirs(path, owner)
41	if err != nil {
42		return Closed(), err
43	}
44	if missing {
45		return Builtin(), &fs.PathError{Op: "open", Path: path, Err: unix.ENOENT}
46	}
47	var fd int
48	for {
49		fd, err = unix.Open(path, unix.O_RDONLY|unix.O_NOFOLLOW|unix.O_NONBLOCK|unix.O_CLOEXEC, 0)
50		if err != unix.EINTR {
51			break
52		}
53	}
54	if err != nil {
55		if errors.Is(err, unix.ENOENT) {
56			return Builtin(), &fs.PathError{Op: "open", Path: path, Err: unix.ENOENT}
57		}
58		if errors.Is(err, unix.ELOOP) {
59			return Closed(), fmt.Errorf("%s: is a symbolic link", path)
60		}
61		return Closed(), &fs.PathError{Op: "open", Path: path, Err: err}
62	}
63	f := os.NewFile(uintptr(fd), path)
64	defer f.Close()
65	var st unix.Stat_t
66	if err := unix.Fstat(fd, &st); err != nil {
67		return Closed(), &fs.PathError{Op: "stat", Path: path, Err: err}
68	}
69	switch {
70	case st.Mode&unix.S_IFMT != unix.S_IFREG:
71		return Closed(), fmt.Errorf("%s: not a regular file", path)
72	case st.Uid != owner:
73		return Closed(), fmt.Errorf("%s: owned by uid %d, want %d", path, st.Uid, owner)
74	case st.Mode&0o022 != 0:
75		return Closed(), fmt.Errorf("%s: writable by group or others (mode %04o)", path, st.Mode&0o7777)
76	}
77	// Reads of a regular file do not block; leave non-blocking mode off so the
78	// os.File does not go through the poller.
79	_ = unix.SetNonblock(fd, false)
80	p, err := Parse(f)
81	if err != nil {
82		return Closed(), err
83	}
84	return p, nil
85}
86
87// checkDirs checks every directory from / to the one holding path. It reports
88// missing when that last directory does not exist.
89func checkDirs(path string, owner uint32) (missing bool, err error) {
90	var chain []string
91	for d := filepath.Dir(path); ; d = filepath.Dir(d) {
92		chain = append(chain, d)
93		if d == "/" {
94			break
95		}
96	}
97	slices.Reverse(chain)
98	for i, d := range chain {
99		fi, err := os.Lstat(d)
100		if err != nil {
101			if errors.Is(err, fs.ErrNotExist) && i == len(chain)-1 {
102				return true, nil
103			}
104			return false, err
105		}
106		st, ok := fi.Sys().(*syscall.Stat_t)
107		switch {
108		case !fi.IsDir():
109			return false, fmt.Errorf("%s: not a real directory (symbolic link?)", d)
110		case !ok:
111			return false, fmt.Errorf("%s: owner unknown", d)
112		case st.Uid != 0 && st.Uid != owner:
113			return false, fmt.Errorf("%s: owned by uid %d, want root", d, st.Uid)
114		case fi.Mode().Perm()&0o022 != 0 && fi.Mode()&os.ModeSticky == 0:
115			return false, fmt.Errorf("%s: writable by group or others (mode %04o)", d, fi.Mode().Perm())
116		}
117	}
118	return false, nil
119}
120
121// Store holds the current policy and reloads it when the file changes. Policy
122// is safe from any goroutine.
123type Store struct {
124	path  string
125	owner uint32
126	log   zerowrap.Logger
127	cur   atomic.Pointer[Policy]
128	// retryInterval is the wait before watching a missing directory again.
129	retryInterval time.Duration
130}
131
132// NewStore loads path once and logs the outcome: an Info when the file is
133// missing (the built-in list is then in force), an Error naming the line when it is
134// invalid (no client may capture). Run keeps it current.
135func NewStore(path string, log zerowrap.Logger) *Store {
136	return NewStoreOwnedBy(path, 0, log)
137}
138
139// NewStoreOwnedBy is NewStore for a file owned by owner instead of root, which
140// then may also own its directories. For tests only, which cannot create
141// root-owned files: the compositor uses NewStore.
142func NewStoreOwnedBy(path string, owner uint32, log zerowrap.Logger) *Store {
143	s := &Store{path: path, owner: owner, log: log, retryInterval: 2 * time.Second}
144	s.reload(true, false)
145	return s
146}
147
148// Policy is the policy in force.
149func (s *Store) Policy() *Policy { return s.cur.Load() }
150
151// reload loads the file and logs the outcome. With quiet set it logs only
152// when the policy changed: the check that closes the gap between the first
153// load and the watch must not repeat the startup line.
154func (s *Store) reload(startup, quiet bool) {
155	p, err := load(s.path, s.owner)
156	old := s.cur.Swap(p)
157	if quiet && old != nil && old.equal(p) {
158		return
159	}
160	switch {
161	case errors.Is(err, fs.ErrNotExist):
162		s.log.Info().Str("path", s.path).Msg("capture allowlist file missing, using the built-in list")
163	case err != nil:
164		e := s.log.Error().Err(err).Str("path", s.path)
165		var pe *ParseError
166		if errors.As(err, &pe) {
167			e = e.Int("line", pe.Line)
168		}
169		e.Msg("capture allowlist invalid, no client may capture")
170	case p.Mode() == ModeDisabled:
171		s.log.Warn().Str("path", s.path).Msg("capture allowlist disables the check, every client may capture")
172	case startup:
173		s.log.Info().Str("path", s.path).Int("executables", p.Len()).Msg("capture allowlist loaded")
174	default:
175		s.log.Info().Str("path", s.path).Int("executables", p.Len()).Msg("capture allowlist reloaded")
176	}
177}
178
179// Run reloads the file whenever it changes, until ctx is done. It watches the
180// parent directory, so an atomic replacement is seen; a symlinked file's
181// target is not watched. While the directory is missing it retries every two
182// seconds. An error means the watch could not be set up: the policy then
183// stays as loaded ("restart to apply").
184func (s *Store) Run(ctx context.Context) error {
185	fd, err := unix.InotifyInit1(unix.IN_CLOEXEC | unix.IN_NONBLOCK)
186	if err != nil {
187		return err
188	}
189	defer unix.Close(fd)
190	waiter, err := fdwait.New()
191	if err != nil {
192		return err
193	}
194	defer waiter.Close()
195	dir, name := filepath.Dir(s.path), filepath.Base(s.path)
196	wd := -1
197	var retry, pending time.Time
198	quiet := false
199	buf := make([]byte, 4096)
200	for {
201		if ctx.Err() != nil {
202			return nil
203		}
204		if wd < 0 && (retry.IsZero() || !time.Now().Before(retry)) {
205			retry = time.Time{}
206			w, err := unix.InotifyAddWatch(fd, dir, watchMask)
207			switch {
208			case err == nil:
209				wd = w
210				// The file may have changed before the watch existed.
211				pending, quiet = time.Now(), true
212			case errors.Is(err, unix.ENOENT) || errors.Is(err, unix.ENOTDIR):
213				retry = time.Now().Add(s.retryInterval)
214			default:
215				return err
216			}
217		}
218		timeout := 200
219		if !retry.IsZero() {
220			timeout = min(timeout, int(time.Until(retry).Milliseconds())+1)
221		}
222		if !pending.IsZero() {
223			remaining := time.Until(pending)
224			if remaining <= 0 {
225				pending = time.Time{}
226				s.reload(false, quiet)
227				quiet = false
228				continue
229			}
230			timeout = min(timeout, int(remaining.Milliseconds())+1)
231		}
232		fds := []unix.PollFd{{Fd: int32(fd), Events: unix.POLLIN}}
233		if _, err := waiter.Poll(fds, timeout); err != nil {
234			if err == unix.EINTR {
235				continue
236			}
237			return err
238		}
239		if fds[0].Revents&unix.POLLIN == 0 {
240			continue
241		}
242		for {
243			n, err := unix.Read(fd, buf)
244			if err == unix.EINTR {
245				continue
246			}
247			if err != nil || n == 0 {
248				break
249			}
250			for off := 0; off+unix.SizeofInotifyEvent <= n; {
251				ev := (*unix.InotifyEvent)(unsafe.Pointer(&buf[off]))
252				size := unix.SizeofInotifyEvent + int(ev.Len)
253				if off+size > n {
254					break
255				}
256				reload, lost := eventAction(ev.Mask, buf[off+unix.SizeofInotifyEvent:off+size], name)
257				if lost {
258					// The directory is gone: watch again once it is back.
259					wd = -1
260					retry = time.Now().Add(s.retryInterval)
261				}
262				if reload {
263					// Every write restarts the debounce: never read half a file.
264					pending, quiet = time.Now().Add(100*time.Millisecond), false
265				}
266				off += size
267			}
268		}
269	}
270}
271
272// watchMask is what the directory watch listens for.
273const watchMask = unix.IN_CLOSE_WRITE | unix.IN_MODIFY | unix.IN_MOVED_TO | unix.IN_MOVED_FROM | unix.IN_CREATE | unix.IN_DELETE | unix.IN_ATTRIB
274
275// eventAction says what one inotify event asks for: reload the file, and
276// watch the directory again because it was removed. A queue overflow means
277// events were lost, so the file is reloaded whatever it was.
278func eventAction(mask uint32, raw []byte, name string) (reload, lost bool) {
279	if mask&unix.IN_Q_OVERFLOW != 0 {
280		return true, false
281	}
282	if mask&unix.IN_IGNORED != 0 {
283		reload, lost = true, true
284	}
285	for len(raw) > 0 && raw[len(raw)-1] == 0 {
286		raw = raw[:len(raw)-1]
287	}
288	switch {
289	case len(raw) == 0 && mask&unix.IN_ATTRIB != 0:
290		reload = true // the directory itself was chmod'ed or chown'ed
291	case len(raw) > 0 && mask&watchMask != 0 && string(raw) == name:
292		reload = true
293	}
294	return reload, lost
295}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 23

Tags 10

↑↓ moveenter openesc close