v0.2.0 13c6a90

store_test.go RawHistoryFolder

1package captureallow
2
3import (
4	"bytes"
5	"context"
6	"errors"
7	"io/fs"
8	"os"
9	"path/filepath"
10	"strings"
11	"sync"
12	"testing"
13	"time"
14
15	"github.com/bnema/zerowrap"
16	"golang.org/x/sys/unix"
17)
18
19// logBuf is a locked bytes.Buffer, a real io.Writer.
20type logBuf struct {
21	mu sync.Mutex
22	b  bytes.Buffer
23}
24
25func (l *logBuf) Write(p []byte) (int, error) {
26	l.mu.Lock()
27	defer l.mu.Unlock()
28	return l.b.Write(p)
29}
30
31func (l *logBuf) String() string {
32	l.mu.Lock()
33	defer l.mu.Unlock()
34	return l.b.String()
35}
36
37func testLog(out *logBuf) zerowrap.Logger {
38	return zerowrap.New(zerowrap.Config{Level: "debug", Format: "json", Output: out}).WithField("component", "captureallow")
39}
40
41// uid owns every file the tests create, standing in for root.
42var uid = uint32(os.Getuid())
43
44func newTestStore(path string, log zerowrap.Logger) *Store { return NewStoreOwnedBy(path, uid, log) }
45
46func TestLoad(t *testing.T) {
47	dir := t.TempDir()
48	p, err := load(filepath.Join(dir, "missing"), uid)
49	if !errors.Is(err, fs.ErrNotExist) || p.Mode() != ModeList || !p.Allows("/usr/bin/grim") || p.Allows("/usr/bin/other") {
50		t.Fatalf("missing: %v, %v", p.Mode(), err)
51	}
52	bad := filepath.Join(dir, "bad")
53	_ = os.WriteFile(bad, []byte("/ok\nrelative\n"), 0o644)
54	p, err = load(bad, uid)
55	var pe *ParseError
56	if !errors.As(err, &pe) || pe.Line != 2 || p.Mode() != ModeClosed || p.Allows("/ok") {
57		t.Fatalf("invalid: %v, %v", p.Mode(), err)
58	}
59	// A directory is unreadable as a file: fail closed, not disabled.
60	p, err = load(dir, uid)
61	if err == nil || p.Mode() != ModeClosed {
62		t.Fatalf("directory: %v, %v", p.Mode(), err)
63	}
64}
65
66func TestStoreStartupLogs(t *testing.T) {
67	dir := t.TempDir()
68	var out logBuf
69	s := newTestStore(filepath.Join(dir, "capture-allow"), testLog(&out))
70	if !s.Policy().Allows("/usr/bin/grim") || s.Policy().Allows("/any") {
71		t.Fatal("missing file must give the built-in list")
72	}
73	log := out.String()
74	if !strings.Contains(log, `"level":"info"`) || !strings.Contains(log, "capture allowlist file missing, using the built-in list") || !strings.Contains(log, `"component":"captureallow"`) || !strings.Contains(log, filepath.Join(dir, "capture-allow")) {
75		t.Fatalf("log = %s", log)
76	}
77	bad := filepath.Join(dir, "bad")
78	_ = os.WriteFile(bad, []byte("/ok\n\nnope\n"), 0o644)
79	out = logBuf{}
80	s = newTestStore(bad, testLog(&out))
81	if s.Policy().Allows("/ok") {
82		t.Fatal("invalid file must fail closed")
83	}
84	log = out.String()
85	if !strings.Contains(log, `"level":"error"`) || !strings.Contains(log, `"line":3`) {
86		t.Fatalf("log = %s", log)
87	}
88}
89
90func waitFor(t *testing.T, what string, cond func() bool) {
91	t.Helper()
92	deadline := time.Now().Add(5 * time.Second)
93	for !cond() {
94		if time.Now().After(deadline) {
95			t.Fatal("timeout: " + what)
96		}
97		time.Sleep(5 * time.Millisecond)
98	}
99}
100
101func TestStoreReloads(t *testing.T) {
102	dir := t.TempDir()
103	path := filepath.Join(dir, "capture-allow")
104	var out logBuf
105	s := newTestStore(path, testLog(&out))
106	ctx, cancel := context.WithCancel(context.Background())
107	done := make(chan error, 1)
108	go func() { done <- s.Run(ctx) }()
109	defer func() {
110		cancel()
111		if err := <-done; err != nil {
112			t.Errorf("Run: %v", err)
113		}
114	}()
115	write := func(content string) {
116		tmp := path + ".tmp"
117		if err := os.WriteFile(tmp, []byte(content), 0o644); err != nil {
118			t.Fatal(err)
119		}
120		if err := os.Rename(tmp, path); err != nil { // atomic, as packages and editors do
121			t.Fatal(err)
122		}
123	}
124	write("/usr/bin/grim\n")
125	waitFor(t, "list", func() bool { return s.Policy().Mode() == ModeList && s.Policy().Allows("/usr/bin/grim") })
126	if s.Policy().Allows("/usr/bin/other") {
127		t.Fatal("unlisted allowed")
128	}
129	write("/usr/bin/other\nbroken\n")
130	waitFor(t, "closed", func() bool { return s.Policy().Mode() == ModeClosed })
131	write("*\n")
132	waitFor(t, "disabled", func() bool { return s.Policy().Mode() == ModeDisabled })
133	// In-place rewrite.
134	if err := os.WriteFile(path, []byte("/usr/bin/third\n"), 0o644); err != nil {
135		t.Fatal(err)
136	}
137	waitFor(t, "in place", func() bool { return s.Policy().Allows("/usr/bin/third") && !s.Policy().Allows("/x") })
138	if err := os.Remove(path); err != nil {
139		t.Fatal(err)
140	}
141	waitFor(t, "removed", func() bool { return s.Policy().Allows("/usr/bin/grim") && !s.Policy().Allows("/usr/bin/third") })
142	if s.Policy().Mode() != ModeList || s.Policy().Len() != Builtin().Len() {
143		t.Fatal("deleting the file must restore the built-in list")
144	}
145}
146
147func TestStoreWaitsForMissingDirectory(t *testing.T) {
148	dir := filepath.Join(t.TempDir(), "etc", "neferwl")
149	path := filepath.Join(dir, "capture-allow")
150	var out logBuf
151	s := newTestStore(path, testLog(&out))
152	s.retryInterval = 20 * time.Millisecond
153	ctx, cancel := context.WithCancel(context.Background())
154	done := make(chan error, 1)
155	go func() { done <- s.Run(ctx) }()
156	defer func() { cancel(); <-done }()
157	time.Sleep(60 * time.Millisecond)
158	if err := os.MkdirAll(dir, 0o755); err != nil {
159		t.Fatal(err)
160	}
161	if err := os.WriteFile(path, []byte("/usr/bin/other\n"), 0o644); err != nil {
162		t.Fatal(err)
163	}
164	waitFor(t, "list after mkdir", func() bool { return s.Policy().Mode() == ModeList && s.Policy().Allows("/usr/bin/other") })
165}
166
167func mustWrite(t *testing.T, path, content string, mode os.FileMode) {
168	t.Helper()
169	if err := os.WriteFile(path, []byte(content), mode); err != nil {
170		t.Fatal(err)
171	}
172	if err := os.Chmod(path, mode); err != nil { // umask
173		t.Fatal(err)
174	}
175}
176
177// Every way the file or its directories can be untrustworthy fails closed.
178func TestLoadRefusesUntrusted(t *testing.T) {
179	good := func(t *testing.T) (dir, path string) {
180		dir = filepath.Join(t.TempDir(), "neferwl")
181		if err := os.Mkdir(dir, 0o755); err != nil {
182			t.Fatal(err)
183		}
184		path = filepath.Join(dir, "capture-allow")
185		mustWrite(t, path, "/usr/bin/grim\n", 0o644)
186		return dir, path
187	}
188	if p, err := load(func() string { _, p := good(t); return p }(), uid); err != nil || !p.Allows("/usr/bin/grim") {
189		t.Fatalf("trusted file: %v", err)
190	}
191	for _, tc := range []struct {
192		name  string
193		setup func(t *testing.T, dir, path string) string
194		owner uint32
195		want  string
196	}{
197		{"group writable file", func(t *testing.T, _, path string) string { mustWrite(t, path, "*\n", 0o664); return path }, uid, "writable by group or others"},
198		{"other writable file", func(t *testing.T, _, path string) string { mustWrite(t, path, "*\n", 0o646); return path }, uid, "writable by group or others"},
199		{"wrong owner", func(t *testing.T, _, path string) string { return path }, uid + 1, "owned by uid"},
200		{"symlink file", func(t *testing.T, dir, path string) string {
201			link := filepath.Join(dir, "link")
202			if err := os.Symlink(path, link); err != nil {
203				t.Fatal(err)
204			}
205			return link
206		}, uid, "symbolic link"},
207		{"fifo", func(t *testing.T, dir, _ string) string {
208			p := filepath.Join(dir, "fifo")
209			if err := unix.Mkfifo(p, 0o644); err != nil {
210				t.Fatal(err)
211			}
212			return p
213		}, uid, "not a regular file"},
214		{"group writable directory", func(t *testing.T, dir, path string) string {
215			if err := os.Chmod(dir, 0o775); err != nil {
216				t.Fatal(err)
217			}
218			return path
219		}, uid, "writable by group or others"},
220		{"symlinked directory", func(t *testing.T, dir, path string) string {
221			link := filepath.Join(filepath.Dir(dir), "via")
222			if err := os.Symlink(dir, link); err != nil {
223				t.Fatal(err)
224			}
225			return filepath.Join(link, "capture-allow")
226		}, uid, "not a real directory"},
227		{"directory owned by someone else", func(t *testing.T, _, path string) string { return path }, 1, "owned by uid"},
228		{"missing ancestor", func(t *testing.T, dir, _ string) string {
229			return filepath.Join(dir, "gone", "deeper", "capture-allow")
230		}, uid, "no such file"},
231	} {
232		t.Run(tc.name, func(t *testing.T) {
233			dir, path := good(t)
234			path = tc.setup(t, dir, path)
235			p, err := load(path, tc.owner)
236			if err == nil || p.Mode() != ModeClosed || p.Allows("/usr/bin/grim") || !strings.Contains(err.Error(), tc.want) {
237				t.Fatalf("mode %v, err %v; want closed, %q", p.Mode(), err, tc.want)
238			}
239		})
240	}
241	// A sticky, world-writable ancestor (like /tmp) is fine; the directory
242	// holding the file being absent gives the built-in list.
243	t.Run("missing holding directory", func(t *testing.T) {
244		dir, _ := good(t)
245		p, err := load(filepath.Join(dir, "sub", "capture-allow"), uid)
246		if !errors.Is(err, fs.ErrNotExist) || p.Mode() != ModeList || !p.Allows("/usr/bin/grim") || p.Allows("/usr/bin/other") {
247			t.Fatalf("mode %v, err %v", p.Mode(), err)
248		}
249	})
250	t.Run("sticky directory", func(t *testing.T) {
251		dir, path := good(t)
252		if err := os.Chmod(dir, 0o777|os.ModeSticky); err != nil {
253			t.Fatal(err)
254		}
255		if p, err := load(path, uid); err != nil || p.Mode() != ModeList {
256			t.Fatalf("mode %v, err %v", p.Mode(), err)
257		}
258	})
259}
260
261// The real production check: this file is not root's, so load closes.
262func TestLoadProductionOwner(t *testing.T) {
263	if os.Getuid() == 0 {
264		t.Skip("running as root")
265	}
266	path := filepath.Join(t.TempDir(), "capture-allow")
267	mustWrite(t, path, "*\n", 0o644)
268	if p, err := load(path, 0); err == nil || p.Mode() != ModeClosed {
269		t.Fatalf("mode %v, err %v", p.Mode(), err)
270	}
271}
272
273func TestEventAction(t *testing.T) {
274	name := []byte("capture-allow\x00\x00\x00")
275	for _, tc := range []struct {
276		mask         uint32
277		raw          []byte
278		reload, lost bool
279	}{
280		{unix.IN_Q_OVERFLOW, nil, true, false},
281		{unix.IN_IGNORED, nil, true, true},
282		{unix.IN_CLOSE_WRITE, name, true, false},
283		{unix.IN_ATTRIB, name, true, false},
284		{unix.IN_ATTRIB, nil, true, false},
285		{unix.IN_CLOSE_WRITE, []byte("other\x00\x00\x00"), false, false},
286	} {
287		if reload, lost := eventAction(tc.mask, tc.raw, "capture-allow"); reload != tc.reload || lost != tc.lost {
288			t.Errorf("mask %#x %q = %v, %v; want %v, %v", tc.mask, tc.raw, reload, lost, tc.reload, tc.lost)
289		}
290	}
291}
292
293func TestStoreLogsUntrusted(t *testing.T) {
294	dir := t.TempDir()
295	path := filepath.Join(dir, "capture-allow")
296	mustWrite(t, path, "*\n", 0o666)
297	var out logBuf
298	s := newTestStore(path, testLog(&out))
299	if s.Policy().Allows("/any") || !strings.Contains(out.String(), `"level":"error"`) || !strings.Contains(out.String(), "writable by group or others") {
300		t.Fatalf("log = %s", out.String())
301	}
302}

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 25

Tags 10

↑↓ moveenter openesc close