Monorepo for Tangled
tangled.org
1//go:build linux
2
3package sandbox
4
5import (
6 "os/exec"
7 "reflect"
8 "strings"
9 "syscall"
10 "testing"
11)
12
13func TestBuildRuleSpec_SingleRepo(t *testing.T) {
14 spec := buildRuleSpec([]string{"/home/git/did:plc:abc"}, "/home/git")
15
16 if got, want := spec.GitConfigRO, "/home/git/.config/git/config"; got != want {
17 t.Errorf("GitConfigRO = %q, want %q", got, want)
18 }
19 if got, want := spec.RepoRW, []string{"/home/git/did:plc:abc"}; !reflect.DeepEqual(got, want) {
20 t.Errorf("RepoRW = %q, want %q", got, want)
21 }
22 if got, want := spec.SystemRO, []string{"/usr", "/bin", "/lib", "/lib64", "/nix", "/etc"}; !reflect.DeepEqual(got, want) {
23 t.Errorf("SystemRO = %q, want %q", got, want)
24 }
25 if got, want := spec.TmpRW, []string{"/tmp"}; !reflect.DeepEqual(got, want) {
26 t.Errorf("TmpRW = %q, want %q", got, want)
27 }
28 if got, want := spec.DevRW, []string{"/dev"}; !reflect.DeepEqual(got, want) {
29 t.Errorf("DevRW = %q, want %q", got, want)
30 }
31}
32
33func TestBuildRuleSpec_GitConfigFollowsHome(t *testing.T) {
34 // the granted git config path must follow $HOME, not the repo path. this
35 // is what makes the merge case work: tmpDir is under /tmp, but the
36 // subprocess still resolves the global config from $HOME/.config/git/config.
37 spec := buildRuleSpec([]string{"/tmp/git-clone-XYZ"}, "/home/git")
38
39 if got, want := spec.GitConfigRO, "/home/git/.config/git/config"; got != want {
40 t.Errorf("GitConfigRO = %q, want %q", got, want)
41 }
42}
43
44func TestBuildRuleSpec_NoHome(t *testing.T) {
45 // empty $HOME should not produce a bogus "/.config/git/config" entry.
46 spec := buildRuleSpec([]string{"/home/git/did:plc:abc"}, "")
47
48 if spec.GitConfigRO != "" {
49 t.Errorf("GitConfigRO = %q, want empty", spec.GitConfigRO)
50 }
51}
52
53func TestBuildRuleSpec_NeverGrantsScanPath(t *testing.T) {
54 // the scan path (the repo's parent) must NEVER appear in any RW or RO
55 // list. granting it would expose other repos and the knot DB via
56 // Landlock RO + DAC group bits. this is the key invariant the rule
57 // tightening was meant to enforce.
58 spec := buildRuleSpec([]string{"/home/git/did:plc:abc"}, "/home/git")
59
60 parent := "/home/git"
61 for _, group := range [][]string{spec.SystemRO, spec.DevRW, spec.TmpRW, spec.RepoRW} {
62 for _, p := range group {
63 if p == parent {
64 t.Errorf("scan path %q must not appear in the ruleset; found in %q", parent, group)
65 }
66 }
67 }
68 if spec.GitConfigRO == parent {
69 t.Errorf("scan path %q must not be granted as GitConfigRO", parent)
70 }
71}
72
73func TestBuildRuleSpec_EmptyInput(t *testing.T) {
74 spec := buildRuleSpec(nil, "")
75 if spec.GitConfigRO != "" {
76 t.Errorf("GitConfigRO should be empty for nil input and no $HOME, got %q", spec.GitConfigRO)
77 }
78 if len(spec.RepoRW) != 0 {
79 t.Errorf("RepoRW should be empty for nil input, got %q", spec.RepoRW)
80 }
81}
82
83func TestLandlockBackend_Name(t *testing.T) {
84 if (&LandlockBackend{}).Name() != "landlock" {
85 t.Error("Name should return \"landlock\"")
86 }
87}
88
89func TestLandlockBackend_WrapMulti_NoPaths(t *testing.T) {
90 sb := &LandlockBackend{selfExe: "/proc/self/exe"}
91 cmd := exec.Command("git", "status")
92
93 wrapped, err := sb.WrapMulti(nil, cmd)
94 if err != nil {
95 t.Fatalf("WrapMulti: %v", err)
96 }
97 if wrapped != cmd {
98 t.Error("empty paths should return the original cmd unchanged")
99 }
100}
101
102func TestLandlockBackend_WrapMulti_ArgsConstruction(t *testing.T) {
103 sb := &LandlockBackend{selfExe: "/path/to/knot"}
104 cmd := exec.Command("git", "upload-pack", "--stateless-rpc", ".")
105 cmd.Env = []string{"GIT_PROTOCOL=version=2"}
106 cmd.Stdin = strings.NewReader("input")
107
108 wrapped, err := sb.WrapMulti([]string{"/repos/a", "/repos/b"}, cmd)
109 if err != nil {
110 t.Fatalf("WrapMulti: %v", err)
111 }
112
113 // argv[0] is the selfExe
114 if wrapped.Path != "/path/to/knot" {
115 t.Errorf("Path = %q, want %q", wrapped.Path, "/path/to/knot")
116 }
117
118 // argv should be: [knot, sandbox-exec, --repo-path=/repos/a, --repo-path=/repos/b, --, <abs-git>, upload-pack, ...]
119 args := wrapped.Args
120 if len(args) < 6 {
121 t.Fatalf("Args too short: %v", args)
122 }
123 if args[0] != "/path/to/knot" {
124 t.Errorf("Args[0] = %q, want %q", args[0], "/path/to/knot")
125 }
126 if args[1] != "sandbox-exec" {
127 t.Errorf("Args[1] = %q, want %q", args[1], "sandbox-exec")
128 }
129 if args[2] != "--repo-path=/repos/a" {
130 t.Errorf("Args[2] = %q, want --repo-path=/repos/a", args[2])
131 }
132 if args[3] != "--repo-path=/repos/b" {
133 t.Errorf("Args[3] = %q, want --repo-path=/repos/b", args[3])
134 }
135 if args[4] != "--" {
136 t.Errorf("Args[4] = %q, want --", args[4])
137 }
138 // args[5] is the resolved absolute git path; just check it ends with /git
139 if !strings.HasSuffix(args[5], "/git") && args[5] != "git" {
140 t.Errorf("Args[5] = %q, want path ending in /git or bare \"git\"", args[5])
141 }
142 if got := args[len(args)-1]; got != "." {
143 t.Errorf("last arg = %q, want %q", got, ".")
144 }
145
146 // Dir should be the first repo path so the kernel chdirs there
147 // after setuid, before execve.
148 if wrapped.Dir != "/repos/a" {
149 t.Errorf("Dir = %q, want %q", wrapped.Dir, "/repos/a")
150 }
151
152 // Env propagated
153 if len(wrapped.Env) != 1 || wrapped.Env[0] != "GIT_PROTOCOL=version=2" {
154 t.Errorf("Env = %v, want [GIT_PROTOCOL=version=2]", wrapped.Env)
155 }
156
157 // Stdio propagated
158 if wrapped.Stdin != cmd.Stdin {
159 t.Error("Stdin not propagated to wrapped cmd")
160 }
161}
162
163func TestLandlockBackend_WrapMulti_NoLookupNoCredential(t *testing.T) {
164 sb := &LandlockBackend{selfExe: "/path/to/knot"} // no lookup
165 cmd := exec.Command("git", "status")
166
167 wrapped, err := sb.WrapMulti([]string{"/repos/a"}, cmd)
168 if err != nil {
169 t.Fatalf("WrapMulti: %v", err)
170 }
171 if wrapped.SysProcAttr != nil && wrapped.SysProcAttr.Credential != nil {
172 t.Error("no lookup configured; Credential should not be set")
173 }
174}
175
176func TestLandlockBackend_WrapMulti_LookupSetsCredential(t *testing.T) {
177 // lookup deliberately returns a different gid (the service group) to
178 // confirm that WrapMulti ignores it and uses uid as the primary gid.
179 // see the comment in sandbox_linux.go for why this matters.
180 sb := &LandlockBackend{
181 selfExe: "/path/to/knot",
182 lookup: func(repoPath string) (uint32, uint32, error) {
183 if repoPath != "/repos/a" {
184 t.Errorf("lookup called with %q, want /repos/a", repoPath)
185 }
186 return 100042, 1234, nil
187 },
188 }
189 cmd := exec.Command("git", "status")
190
191 wrapped, err := sb.WrapMulti([]string{"/repos/a"}, cmd)
192 if err != nil {
193 t.Fatalf("WrapMulti: %v", err)
194 }
195 if wrapped.SysProcAttr == nil || wrapped.SysProcAttr.Credential == nil {
196 t.Fatal("Credential should be set when lookup returns uid > 0")
197 }
198 cred := wrapped.SysProcAttr.Credential
199 if cred.Uid != 100042 {
200 t.Errorf("Credential.Uid = %d, want 100042", cred.Uid)
201 }
202 if cred.Gid != 100042 {
203 t.Errorf("Credential.Gid = %d, want 100042 (must equal Uid, not lookup's gid 1234)", cred.Gid)
204 }
205 if !cred.NoSetGroups {
206 t.Error("NoSetGroups should be true")
207 }
208}
209
210func TestLandlockBackend_WrapMulti_LookupErrSkipsCredential(t *testing.T) {
211 sb := &LandlockBackend{
212 selfExe: "/path/to/knot",
213 lookup: func(string) (uint32, uint32, error) {
214 return 0, 0, syscall.ENOENT
215 },
216 }
217 cmd := exec.Command("git", "status")
218
219 wrapped, err := sb.WrapMulti([]string{"/repos/a"}, cmd)
220 if err != nil {
221 t.Fatalf("WrapMulti: %v", err)
222 }
223 if wrapped.SysProcAttr != nil && wrapped.SysProcAttr.Credential != nil {
224 t.Error("lookup errored; Credential should not be set")
225 }
226}
227
228func TestLandlockBackend_WrapMulti_LookupZeroSkipsCredential(t *testing.T) {
229 // uid == 0 is treated as "don't drop" so we never accidentally drop to
230 // root. Verify Credential isn't set in that case.
231 sb := &LandlockBackend{
232 selfExe: "/path/to/knot",
233 lookup: func(string) (uint32, uint32, error) {
234 return 0, 0, nil
235 },
236 }
237 cmd := exec.Command("git", "status")
238
239 wrapped, err := sb.WrapMulti([]string{"/repos/a"}, cmd)
240 if err != nil {
241 t.Fatalf("WrapMulti: %v", err)
242 }
243 if wrapped.SysProcAttr != nil && wrapped.SysProcAttr.Credential != nil {
244 t.Error("lookup returned uid=0; Credential should not be set")
245 }
246}