Monorepo for Tangled
tangled.org
1//go:build linux && integration
2
3package sandbox
4
5import (
6 "fmt"
7 "os"
8 "os/exec"
9 "path/filepath"
10 "strings"
11 "testing"
12
13 "github.com/landlock-lsm/go-landlock/landlock"
14)
15
16// This file exercises actual Landlock enforcement. It works by re-execing the
17// test binary with an env var that makes it run the "child" code path: the
18// child applies the ruleset built by BuildRuleSpec and attempts a specific
19// filesystem operation, exiting 0 (allowed) or non-zero (denied).
20//
21// Build tag: linux && integration. Run with:
22// go test -tags integration ./knotserver/sandbox/...
23
24const (
25 childEnv = "TANGLED_SANDBOX_INT_CHILD"
26 childRepo = "TANGLED_SANDBOX_INT_REPO"
27 childTarget = "TANGLED_SANDBOX_INT_TARGET"
28 childOp = "TANGLED_SANDBOX_INT_OP"
29)
30
31func TestMain(m *testing.M) {
32 if os.Getenv(childEnv) == "1" {
33 runChild()
34 return
35 }
36 os.Exit(m.Run())
37}
38
39func runChild() {
40 repoPath := os.Getenv(childRepo)
41 target := os.Getenv(childTarget)
42 op := os.Getenv(childOp)
43
44 spec := BuildRuleSpec([]string{repoPath})
45 rules := []landlock.Rule{
46 landlock.RODirs(spec.SystemRO...).IgnoreIfMissing(),
47 landlock.RWFiles(spec.DevRW...).WithIoctlDev().IgnoreIfMissing(),
48 landlock.RWDirs(spec.TmpRW...).IgnoreIfMissing(),
49 }
50 if spec.GitConfigRO != "" {
51 rules = append(rules, landlock.ROFiles(spec.GitConfigRO).IgnoreIfMissing())
52 }
53 for _, p := range spec.RepoRW {
54 rules = append(rules, landlock.RWDirs(p).WithRefer())
55 }
56 if err := landlock.V8.BestEffort().RestrictPaths(rules...); err != nil {
57 fmt.Fprintf(os.Stderr, "restrict failed: %v\n", err)
58 os.Exit(2)
59 }
60
61 var opErr error
62 switch op {
63 case "read":
64 _, opErr = os.ReadFile(target)
65 case "write":
66 opErr = os.WriteFile(target, []byte("x"), 0644)
67 case "list":
68 _, opErr = os.ReadDir(target)
69 default:
70 fmt.Fprintf(os.Stderr, "unknown op %q\n", op)
71 os.Exit(3)
72 }
73
74 if opErr != nil {
75 fmt.Fprintln(os.Stderr, opErr)
76 os.Exit(1)
77 }
78 os.Exit(0)
79}
80
81// runUnderSandbox spawns the test binary as a child, applies Landlock with
82// the given repoPath as the granted RW path, then attempts op on target.
83// Returns true if the op was allowed, false if denied.
84func runUnderSandbox(t *testing.T, repoPath, target, op string) (allowed bool, output string) {
85 t.Helper()
86 cmd := exec.Command(os.Args[0])
87 cmd.Env = append(os.Environ(),
88 childEnv+"=1",
89 childRepo+"="+repoPath,
90 childTarget+"="+target,
91 childOp+"="+op,
92 )
93 out, err := cmd.CombinedOutput()
94 if err == nil {
95 return true, string(out)
96 }
97 if exit, ok := err.(*exec.ExitError); ok && exit.ExitCode() == 1 {
98 return false, string(out)
99 }
100 t.Fatalf("child exited unexpectedly: %v\noutput: %s", err, out)
101 return false, ""
102}
103
104func skipIfNoLandlock(t *testing.T) {
105 t.Helper()
106 if !probeLandlock() {
107 t.Skip("Landlock not available on this kernel")
108 }
109}
110
111func TestSandboxIntegration_AllowsOwnRepoRead(t *testing.T) {
112 skipIfNoLandlock(t)
113
114 root := t.TempDir()
115 repo := filepath.Join(root, "did:plc:abc")
116 target := filepath.Join(repo, "HEAD")
117 mustMkdirAll(t, repo)
118 mustWriteFile(t, target, "ref: refs/heads/main\n")
119
120 allowed, out := runUnderSandbox(t, repo, target, "read")
121 if !allowed {
122 t.Errorf("expected read of own repo to be allowed; child said: %s", strings.TrimSpace(out))
123 }
124}
125
126func TestSandboxIntegration_DeniesOtherRepoRead(t *testing.T) {
127 skipIfNoLandlock(t)
128
129 root := t.TempDir()
130 myRepo := filepath.Join(root, "did:plc:abc")
131 otherRepo := filepath.Join(root, "did:plc:xyz")
132 secret := filepath.Join(otherRepo, "secret-key")
133 mustMkdirAll(t, myRepo)
134 mustMkdirAll(t, otherRepo)
135 mustWriteFile(t, secret, "TOPSECRET\n")
136
137 allowed, out := runUnderSandbox(t, myRepo, secret, "read")
138 if allowed {
139 t.Errorf("expected read of other repo to be denied; child read: %s", strings.TrimSpace(out))
140 }
141}
142
143func TestSandboxIntegration_AllowsGlobalConfigRead(t *testing.T) {
144 skipIfNoLandlock(t)
145
146 root := t.TempDir()
147 myRepo := filepath.Join(root, "did:plc:abc")
148 cfgDir := filepath.Join(root, ".config", "git")
149 cfg := filepath.Join(cfgDir, "config")
150 mustMkdirAll(t, myRepo)
151 mustMkdirAll(t, cfgDir)
152 mustWriteFile(t, cfg, "[user]\n\tname = test\n")
153
154 // the child reads $HOME via BuildRuleSpec, so set HOME for it explicitly.
155 t.Setenv("HOME", root)
156
157 allowed, out := runUnderSandbox(t, myRepo, cfg, "read")
158 if !allowed {
159 t.Errorf("expected read of $HOME/.config/git/config to be allowed; child said: %s", strings.TrimSpace(out))
160 }
161}
162
163func TestSandboxIntegration_DeniesGlobalConfigSibling(t *testing.T) {
164 skipIfNoLandlock(t)
165
166 root := t.TempDir()
167 myRepo := filepath.Join(root, "did:plc:abc")
168 cfgDir := filepath.Join(root, ".config", "git")
169 sibling := filepath.Join(cfgDir, "attributes")
170 mustMkdirAll(t, myRepo)
171 mustMkdirAll(t, cfgDir)
172 mustWriteFile(t, sibling, "*.bin -text\n")
173
174 t.Setenv("HOME", root)
175
176 allowed, out := runUnderSandbox(t, myRepo, sibling, "read")
177 if allowed {
178 t.Errorf("expected read of sibling in .config/git/ to be denied (only the config file is granted); child read: %s", strings.TrimSpace(out))
179 }
180}
181
182func TestSandboxIntegration_DeniesScanPathSibling(t *testing.T) {
183 skipIfNoLandlock(t)
184
185 root := t.TempDir()
186 myRepo := filepath.Join(root, "did:plc:abc")
187 dbFile := filepath.Join(root, "knotserver.db")
188 mustMkdirAll(t, myRepo)
189 mustWriteFile(t, dbFile, "fake db contents\n")
190
191 allowed, out := runUnderSandbox(t, myRepo, dbFile, "read")
192 if allowed {
193 t.Errorf("expected read of sibling file (knotserver.db) to be denied; child read: %s", strings.TrimSpace(out))
194 }
195}
196
197func TestSandboxIntegration_DeniesScanPathListing(t *testing.T) {
198 skipIfNoLandlock(t)
199
200 root := t.TempDir()
201 myRepo := filepath.Join(root, "did:plc:abc")
202 mustMkdirAll(t, myRepo)
203
204 allowed, out := runUnderSandbox(t, myRepo, root, "list")
205 if allowed {
206 t.Errorf("expected listing scan path to be denied; child output: %s", strings.TrimSpace(out))
207 }
208}
209
210func TestSandboxIntegration_AllowsOwnRepoWrite(t *testing.T) {
211 skipIfNoLandlock(t)
212
213 root := t.TempDir()
214 repo := filepath.Join(root, "did:plc:abc")
215 target := filepath.Join(repo, "new-file")
216 mustMkdirAll(t, repo)
217
218 allowed, out := runUnderSandbox(t, repo, target, "write")
219 if !allowed {
220 t.Errorf("expected write to own repo to be allowed; child said: %s", strings.TrimSpace(out))
221 }
222}
223
224func TestSandboxIntegration_DeniesSystemWrite(t *testing.T) {
225 skipIfNoLandlock(t)
226
227 root := t.TempDir()
228 repo := filepath.Join(root, "did:plc:abc")
229 mustMkdirAll(t, repo)
230
231 // /etc is granted RO; writes must be denied.
232 allowed, out := runUnderSandbox(t, repo, "/etc/sandbox-test-should-fail", "write")
233 if allowed {
234 t.Errorf("expected write to /etc to be denied; child output: %s", strings.TrimSpace(out))
235 }
236}
237
238func mustMkdirAll(t *testing.T, p string) {
239 t.Helper()
240 if err := os.MkdirAll(p, 0o755); err != nil {
241 t.Fatal(err)
242 }
243}
244
245func mustWriteFile(t *testing.T, p, content string) {
246 t.Helper()
247 if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
248 t.Fatal(err)
249 }
250}