app_bind_policy_test.go RawHistoryFolder

1package domain
2
3import (
4	"net"
5	"os"
6	"path/filepath"
7	"syscall"
8	"testing"
9
10	"github.com/stretchr/testify/assert"
11	"github.com/stretchr/testify/require"
12)
13
14func validPolicy(root, source string) AppBindPolicy {
15	return AppBindPolicy{
16		Name:            "config",
17		Source:          source,
18		AllowedApps:     []string{"blog"},
19		AllowedServices: []string{"web"},
20		Root:            root,
21	}
22}
23
24func TestAppBindPolicy_Validate(t *testing.T) {
25	root := t.TempDir()
26	source := filepath.Join(root, "binds")
27	require.NoError(t, os.MkdirAll(source, 0o755))
28
29	require.NoError(t, validPolicy(root, source).Validate())
30
31	cases := []struct {
32		name    string
33		mutate  func(*AppBindPolicy)
34		wantErr string
35	}{
36		{"empty name", func(p *AppBindPolicy) { p.Name = "" }, "policy name"},
37		{"bad name", func(p *AppBindPolicy) { p.Name = "Bad" }, "policy name"},
38		{"reserved name", func(p *AppBindPolicy) { p.Name = "a--b" }, "reserved separator"},
39		{"relative source", func(p *AppBindPolicy) { p.Source = "binds" }, "must be absolute"},
40		{"unclean source", func(p *AppBindPolicy) { p.Source = root + "/binds/../etc" }, "normalized clean path"},
41		{"empty source", func(p *AppBindPolicy) { p.Source = "" }, "source must not be empty"},
42		{"relative root", func(p *AppBindPolicy) { p.Root = "root" }, "must be absolute"},
43		{"unclean root", func(p *AppBindPolicy) { p.Root = root + "/x/.." }, "normalized clean path"},
44		{"empty allowed apps", func(p *AppBindPolicy) { p.AllowedApps = nil }, "allowed apps must not be empty"},
45		{"empty allowed services", func(p *AppBindPolicy) { p.AllowedServices = nil }, "allowed services must not be empty"},
46		{"bad allowed app", func(p *AppBindPolicy) { p.AllowedApps = []string{"Bad!"} }, "allowed app"},
47		{"bad allowed service", func(p *AppBindPolicy) { p.AllowedServices = []string{"Bad!"} }, "allowed service"},
48		{"duplicate allowed app", func(p *AppBindPolicy) { p.AllowedApps = []string{"blog", "blog"} }, "duplicated"},
49		{"duplicate allowed service", func(p *AppBindPolicy) { p.AllowedServices = []string{"web", "web"} }, "duplicated"},
50	}
51	for _, tc := range cases {
52		t.Run(tc.name, func(t *testing.T) {
53			policy := validPolicy(root, source)
54			tc.mutate(&policy)
55			err := policy.Validate()
56			require.Error(t, err)
57			assert.ErrorIs(t, err, ErrBindPolicy)
58			assert.Contains(t, err.Error(), tc.wantErr)
59		})
60	}
61}
62
63func TestAppBindPolicy_ResolveAppBind(t *testing.T) {
64	root := t.TempDir()
65	source := filepath.Join(root, "binds", "config")
66	require.NoError(t, os.MkdirAll(source, 0o755))
67
68	policy := validPolicy(root, source)
69	resolved, err := policy.ResolveAppBind("blog", "web", AppBind{Name: "config", Path: "/etc/app.conf"})
70	require.NoError(t, err)
71	assert.Equal(t, "config", resolved.Name)
72	assert.Equal(t, "/etc/app.conf", resolved.Destination)
73	assert.False(t, resolved.ReadOnly)
74	realSource, err := filepath.EvalSymlinks(source)
75	require.NoError(t, err)
76	assert.Equal(t, realSource, resolved.Source)
77}
78
79func TestAppBindPolicy_ResolveAppBindReadOnlyNonWeakening(t *testing.T) {
80	root := t.TempDir()
81	source := filepath.Join(root, "file.txt")
82	require.NoError(t, os.WriteFile(source, []byte("data"), 0o644))
83
84	cases := []struct {
85		name         string
86		policyRO     bool
87		bindRO       bool
88		wantReadOnly bool
89	}{
90		{"policy enforces read-only", true, false, true},
91		{"bind requests read-only", false, true, true},
92		{"both read-only", true, true, true},
93		{"writable by default", false, false, false},
94	}
95	for _, tc := range cases {
96		t.Run(tc.name, func(t *testing.T) {
97			policy := validPolicy(root, source)
98			policy.ReadOnly = tc.policyRO
99			resolved, err := policy.ResolveAppBind("blog", "web", AppBind{Name: "config", Path: "/etc/app.conf", ReadOnly: tc.bindRO})
100			require.NoError(t, err)
101			assert.Equal(t, tc.wantReadOnly, resolved.ReadOnly)
102		})
103	}
104}
105
106func TestAppBindPolicy_ResolveAppBindAllowlists(t *testing.T) {
107	root := t.TempDir()
108	source := filepath.Join(root, "binds")
109	require.NoError(t, os.MkdirAll(source, 0o755))
110	policy := validPolicy(root, source)
111
112	_, err := policy.ResolveAppBind("other", "web", AppBind{Name: "config", Path: "/etc/app.conf"})
113	require.ErrorIs(t, err, ErrBindPolicy)
114	assert.Contains(t, err.Error(), "does not allow app")
115
116	_, err = policy.ResolveAppBind("blog", "other", AppBind{Name: "config", Path: "/etc/app.conf"})
117	require.ErrorIs(t, err, ErrBindPolicy)
118	assert.Contains(t, err.Error(), "does not allow service")
119
120	// Exact, not prefix, matching.
121	_, err = policy.ResolveAppBind("blog-staging", "web", AppBind{Name: "config", Path: "/etc/app.conf"})
122	require.ErrorIs(t, err, ErrBindPolicy)
123}
124
125func TestAppBindPolicy_ResolveAppBindBindShape(t *testing.T) {
126	root := t.TempDir()
127	source := filepath.Join(root, "binds")
128	require.NoError(t, os.MkdirAll(source, 0o755))
129	policy := validPolicy(root, source)
130
131	_, err := policy.ResolveAppBind("blog", "web", AppBind{Name: "Bad", Path: "/etc/app.conf"})
132	require.ErrorIs(t, err, ErrInvalidAppSpec)
133
134	_, err = policy.ResolveAppBind("blog", "web", AppBind{Name: "config", Path: "relative"})
135	require.ErrorIs(t, err, ErrInvalidAppSpec)
136}
137
138func TestAppBindPolicy_ResolveAppBindRootBoundary(t *testing.T) {
139	root := t.TempDir()
140	outside := t.TempDir()
141	require.NoError(t, os.MkdirAll(filepath.Join(root, "binds"), 0o755))
142	require.NoError(t, os.MkdirAll(filepath.Join(outside, "binds"), 0o755))
143
144	t.Run("source outside root", func(t *testing.T) {
145		policy := validPolicy(root, filepath.Join(outside, "binds"))
146		_, err := policy.ResolveAppBind("blog", "web", AppBind{Name: "config", Path: "/etc/app.conf"})
147		require.ErrorIs(t, err, ErrBindPolicy)
148		assert.Contains(t, err.Error(), "outside root")
149	})
150
151	t.Run("sibling prefix is not inside", func(t *testing.T) {
152		// root/../root-sibling must not be treated as a descendant of root.
153		sibling := root + "-sibling"
154		require.NoError(t, os.MkdirAll(sibling, 0o755))
155		t.Cleanup(func() { _ = os.RemoveAll(sibling) })
156		policy := validPolicy(root, sibling)
157		_, err := policy.ResolveAppBind("blog", "web", AppBind{Name: "config", Path: "/etc/app.conf"})
158		require.ErrorIs(t, err, ErrBindPolicy)
159		assert.Contains(t, err.Error(), "outside root")
160	})
161
162	t.Run("symlink escaping root is refused", func(t *testing.T) {
163		link := filepath.Join(root, "escape")
164		require.NoError(t, os.Symlink(filepath.Join(outside, "binds"), link))
165		policy := validPolicy(root, link)
166		_, err := policy.ResolveAppBind("blog", "web", AppBind{Name: "config", Path: "/etc/app.conf"})
167		require.ErrorIs(t, err, ErrBindPolicy)
168		assert.Contains(t, err.Error(), "outside root")
169	})
170
171	t.Run("symlink inside root resolves", func(t *testing.T) {
172		real := filepath.Join(root, "binds")
173		link := filepath.Join(root, "link")
174		require.NoError(t, os.Symlink(real, link))
175		policy := validPolicy(root, link)
176		resolved, err := policy.ResolveAppBind("blog", "web", AppBind{Name: "config", Path: "/etc/app.conf"})
177		require.NoError(t, err)
178		realSource, err := filepath.EvalSymlinks(real)
179		require.NoError(t, err)
180		assert.Equal(t, realSource, resolved.Source)
181	})
182
183	t.Run("root itself is inside", func(t *testing.T) {
184		policy := validPolicy(root, root)
185		_, err := policy.ResolveAppBind("blog", "web", AppBind{Name: "config", Path: "/etc/app.conf"})
186		require.NoError(t, err)
187	})
188
189	t.Run("optional root is not required", func(t *testing.T) {
190		policy := validPolicy("", filepath.Join(outside, "binds"))
191		_, err := policy.ResolveAppBind("blog", "web", AppBind{Name: "config", Path: "/etc/app.conf"})
192		require.NoError(t, err)
193	})
194}
195
196func TestAppBindPolicy_ResolveAppBindSourceTypes(t *testing.T) {
197	root := t.TempDir()
198	require.NoError(t, os.MkdirAll(filepath.Join(root, "binds"), 0o755))
199
200	t.Run("missing source", func(t *testing.T) {
201		policy := validPolicy(root, filepath.Join(root, "binds", "nope"))
202		_, err := policy.ResolveAppBind("blog", "web", AppBind{Name: "config", Path: "/etc/app.conf"})
203		require.ErrorIs(t, err, ErrBindPolicy)
204	})
205
206	t.Run("fifo refused", func(t *testing.T) {
207		fifo := filepath.Join(root, "binds", "fifo")
208		require.NoError(t, syscall.Mkfifo(fifo, 0o600))
209		policy := validPolicy(root, fifo)
210		_, err := policy.ResolveAppBind("blog", "web", AppBind{Name: "config", Path: "/etc/app.conf"})
211		require.ErrorIs(t, err, ErrBindPolicy)
212		assert.Contains(t, err.Error(), "regular file or directory")
213	})
214
215	t.Run("socket refused", func(t *testing.T) {
216		socket := filepath.Join(root, "binds", "sock")
217		listener, err := net.Listen("unix", socket)
218		require.NoError(t, err)
219		defer func() { _ = listener.Close() }()
220		policy := validPolicy(root, socket)
221		_, err = policy.ResolveAppBind("blog", "web", AppBind{Name: "config", Path: "/etc/app.conf"})
222		require.ErrorIs(t, err, ErrBindPolicy)
223		assert.Contains(t, err.Error(), "regular file or directory")
224	})
225
226	t.Run("regular file accepted", func(t *testing.T) {
227		file := filepath.Join(root, "binds", "app.conf")
228		require.NoError(t, os.WriteFile(file, []byte("x"), 0o644))
229		policy := validPolicy(root, file)
230		resolved, err := policy.ResolveAppBind("blog", "web", AppBind{Name: "config", Path: "/etc/app.conf"})
231		require.NoError(t, err)
232		assert.Equal(t, file, resolved.Source)
233	})
234}
235
236func TestBindSourceTypeAllowed(t *testing.T) {
237	assert.True(t, bindSourceTypeAllowed(0))
238	assert.True(t, bindSourceTypeAllowed(os.ModeDir))
239	refused := []os.FileMode{
240		os.ModeSymlink,
241		os.ModeDevice,
242		os.ModeCharDevice,
243		os.ModeNamedPipe,
244		os.ModeSocket,
245		os.ModeIrregular,
246	}
247	for _, mode := range refused {
248		assert.False(t, bindSourceTypeAllowed(mode), mode.String())
249	}
250}
251
252func TestResolvedUnderRoot(t *testing.T) {
253	assert.True(t, resolvedUnderRoot("/srv/binds", "/srv/binds"))
254	assert.True(t, resolvedUnderRoot("/srv/binds", "/srv/binds/conf"))
255	assert.False(t, resolvedUnderRoot("/srv/binds", "/srv/binds-sibling"))
256	assert.False(t, resolvedUnderRoot("/srv/binds", "/srv/other"))
257	assert.False(t, resolvedUnderRoot("/srv/binds", "/etc"))
258}

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 7

Tags 106

↑↓ moveenter openesc close