1package app
2
3import (
4 "os"
5 "path/filepath"
6 "testing"
7
8 "github.com/stretchr/testify/assert"
9 "github.com/stretchr/testify/require"
10
11 "github.com/bnema/gordon/internal/adapters/out/secretfiles"
12 "github.com/bnema/gordon/internal/domain"
13)
14
15func fakeEnv(values map[string]string) func(string) string {
16 return func(key string) string { return values[key] }
17}
18
19func TestProtectedRuntimePaths_AllSources(t *testing.T) {
20 home := t.TempDir()
21 env := fakeEnv(map[string]string{
22 "XDG_RUNTIME_DIR": "/run/user/1000",
23 "DOCKER_HOST": "unix:///x/d.sock",
24 "GNUPGHOME": "/keys/gnupg",
25 "PASSWORD_STORE_DIR": "/keys/pass",
26 "SOPS_AGE_KEY_FILE": "/keys/age.txt",
27 "XDG_CONFIG_HOME": "/cfg",
28
29 "GORDON_CLOUDFLARE_API_TOKEN_FILE": "/keys/cf-token",
30 })
31 got := protectedRuntimePaths("/data/gordon", []string{"/etc/gordon/gordon.toml", "/custom/podman.sock", ""}, env, home)
32
33 for _, want := range []string{
34 "/data/gordon",
35 "/etc/gordon/gordon.toml",
36 "/var/run/docker.sock",
37 "/run/docker.sock",
38 "/run/podman/podman.sock",
39 "/run/user/1000/docker.sock",
40 "/run/user/1000/podman/podman.sock",
41 "/run/user/1000/gordon",
42 filepath.Join(home, ".gordon", "run"),
43 "/x/d.sock",
44 "/custom/podman.sock",
45 "/keys/gnupg",
46 filepath.Join(home, ".gnupg"),
47 "/keys/pass",
48 filepath.Join(home, ".password-store"),
49 "/keys/age.txt",
50 "/cfg/sops/age",
51 "/keys/cf-token",
52 filepath.Join(home, ".local", "share", "containers"),
53 filepath.Join(home, ".local", "share", "docker"),
54 "/cfg/containers",
55 filepath.Join(home, ".docker"),
56 "/cfg/systemd/user",
57 } {
58 assert.Contains(t, got, want)
59 }
60 assert.IsIncreasing(t, got)
61}
62
63func TestProtectedRuntimePaths_SkipsEmptyAndTCP(t *testing.T) {
64 // Subset, not Equal: on hosts where a fixed path crosses a symlink
65 // (for example /var -> /private/var) its resolved form is added too.
66 got := protectedRuntimePaths("/data", nil, fakeEnv(map[string]string{"DOCKER_HOST": "tcp://h:2375"}), "")
67 assert.Subset(t, got, []string{"/data", "/run/docker.sock", "/run/podman/podman.sock", "/var/run/docker.sock"})
68 for _, p := range got {
69 assert.NotContains(t, p, "tcp:")
70 assert.NotContains(t, p, "h:2375")
71 }
72}
73
74func TestProtectedRuntimePaths_SymlinkAddsBothForms(t *testing.T) {
75 base := t.TempDir()
76 real := filepath.Join(base, "real")
77 require.NoError(t, os.Mkdir(real, 0o700))
78 link := filepath.Join(base, "link")
79 require.NoError(t, os.Symlink(real, link))
80 realResolved, err := filepath.EvalSymlinks(real)
81 require.NoError(t, err)
82
83 got := protectedRuntimePaths(link, nil, fakeEnv(nil), "")
84 assert.Contains(t, got, link)
85 assert.Contains(t, got, realResolved)
86
87 // A protected path that does not exist yet under a symlinked parent
88 // is protected in its resolved form too.
89 missing := filepath.Join(link, "later", "store")
90 got = protectedRuntimePaths("/data", []string{missing}, fakeEnv(nil), "")
91 assert.Contains(t, got, missing)
92 assert.Contains(t, got, filepath.Join(realResolved, "later", "store"))
93}
94
95func TestConfiguredGordonPaths(t *testing.T) {
96 si := &serviceInit{}
97 si.cfg.Server.TLSKeyFile = "/tls/key.pem"
98 si.cfg.Server.DataDir = "/data"
99 si.cfg.Logging.AccessLog.FilePath = "/logs/access.log"
100 si.cfg.Backups.Databases.StorageDir = "/backups"
101
102 got := configuredGordonPaths(si)
103 assert.Contains(t, got, "/tls/key.pem")
104 assert.Contains(t, got, "/backups")
105 assert.NotContains(t, got, "/logs/access.log")
106}
107
108func TestGordonLogLocations(t *testing.T) {
109 var cfg Config
110 cfg.Server.DataDir = "/data"
111 cfg.Logging.AccessLog.FilePath = "/logs/access.log"
112 assert.Empty(t, gordonLogLocations(cfg, "/data"), "disabled logs grant nothing")
113
114 cfg.Logging.File.Enabled = true
115 cfg.Logging.AccessLog.Enabled = true
116 cfg.Logging.AccessLog.Output = "file"
117 cfg.Logging.ContainerLogs.Enabled = true
118 assert.ElementsMatch(t, []logLocation{
119 {dir: "/data/logs", path: "/data/logs/gordon.log"},
120 {dir: "/logs", path: "/logs/access.log"},
121 {dir: "/data/logs/containers", path: "/data/logs/containers"},
122 }, gordonLogLocations(cfg, "/data"))
123
124 cfg.Logging.AccessLog.Output = "stdout"
125 assert.Len(t, gordonLogLocations(cfg, "/data"), 2, "non-file access log grants nothing")
126}
127
128func TestReadableLogDirs(t *testing.T) {
129 protected := []string{"/data", "/home/u", "/home/u/.gnupg", "/srv/logs/keys"}
130 loc := func(dir string) logLocation { return logLocation{dir: dir, path: dir + "/x.log"} }
131 readable, keep := readableLogDirs([]logLocation{
132 loc("/data/logs"),
133 loc("/data/logs/containers"),
134 loc("/data"), // data dir root
135 loc("/data/secrets-run"), // data child outside logs
136 loc("/data/secrets"), // data child outside logs
137 loc("/home/u"), // protected itself
138 loc("/home/u/.gnupg/log"), // under a key store
139 loc("/srv/logs"), // contains a protected path
140 loc("/srv"), // ancestor of a protected path
141 loc("/var/log/gordon"),
142 }, protected, "/data")
143 assert.Equal(t, []string{"/data/logs", "/data/logs/containers", "/var/log/gordon"}, readable)
144 assert.Equal(t, []string{
145 "/data/x.log", "/data/secrets-run/x.log", "/data/secrets/x.log", "/home/u/x.log",
146 "/home/u/.gnupg/log/x.log", "/srv/logs/x.log", "/srv/x.log",
147 }, keep)
148}
149
150func TestBuildAppRuntimeSafety_DroppedLogStaysProtected(t *testing.T) {
151 home := t.TempDir()
152 t.Setenv("HOME", home)
153 si := &serviceInit{}
154 si.cfg.Logging.File.Enabled = true
155 si.cfg.Logging.File.Path = filepath.Join(home, "gordon.log")
156 store, guard, err := buildAppRuntimeSafety(si, t.TempDir())
157 require.NoError(t, err)
158 t.Cleanup(func() { _ = store.(*secretfiles.Store).Close() })
159
160 // $HOME contains key stores, so it is no readable log dir, and the
161 // log file itself stays protected even against a writable bind.
162 assert.NotContains(t, guard.LogDirs, home)
163 err = guard.Check(&domain.ContainerConfig{Binds: []domain.ContainerBind{{Name: "log", Source: filepath.Join(home, "gordon.log"), Destination: "/log"}}})
164 require.ErrorIs(t, err, domain.ErrRuntimeGuard)
165}
166
167func TestBuildAppRuntimeSafety(t *testing.T) {
168 dataDir := t.TempDir()
169 si := &serviceInit{}
170 si.cfg.Logging.ContainerLogs.Enabled = true
171 si.cfg.Logging.File.Enabled = true
172 store, guard, err := buildAppRuntimeSafety(si, dataDir)
173 require.NoError(t, err)
174 require.NotNil(t, store)
175 t.Cleanup(func() { _ = store.(*secretfiles.Store).Close() })
176 assert.Equal(t, filepath.Join(dataDir, "secrets-run"), guard.SecretFilesDir)
177 assert.Contains(t, guard.ProtectedPaths, dataDir)
178
179 // The data dir itself cannot be bound into an app.
180 err = guard.Check(&domain.ContainerConfig{Binds: []domain.ContainerBind{{Name: "all", Source: dataDir, Destination: "/data"}}})
181 require.ErrorIs(t, err, domain.ErrRuntimeGuard)
182
183 // The default log dir under the data dir is readable, never writable.
184 logs := filepath.Join(dataDir, "logs")
185 require.NoError(t, guard.Check(&domain.ContainerConfig{Binds: []domain.ContainerBind{{Name: "logs", Source: logs, Destination: "/var/log/gordon", ReadOnly: true}}}))
186 err = guard.Check(&domain.ContainerConfig{Binds: []domain.ContainerBind{{Name: "logs", Source: logs, Destination: "/var/log/gordon"}}})
187 require.ErrorIs(t, err, domain.ErrRuntimeGuard)
188}