From b2472281a44f379ec27a2e6723f0de6312e18326 Mon Sep 17 00:00:00 2001 From: Anirudh Oppiliappan Date: Mon, 25 May 2026 16:36:43 +0000 Subject: [PATCH] knotserver/sandbox: add tests Signed-off-by: Anirudh Oppiliappan --- knotserver/sandbox/repofs_test.go | 162 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ knotserver/sandbox/sandbox_linux_test.go | 175 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ knotserver/sandbox/sandbox_test.go | 55 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 file(s) changed, 392 insertion(s)(+), 0 deletion(s)(-) diff --git a/knotserver/sandbox/repofs_test.go b/knotserver/sandbox/repofs_test.go new file mode 100644 --- /dev/null +++ b/knotserver/sandbox/repofs_test.go @@ -0,0 +1,162 @@ +package sandbox + +import ( + "os" + "path/filepath" + "runtime" + "testing" +) + +func TestChmodRepoTree(t *testing.T) { + root := t.TempDir() + + // build a tree: + // root/ + // file.txt (0644) + // script.sh (0755) + // subdir/ + // nested.txt (0644) + // link -> ../file.txt + mustWrite(t, filepath.Join(root, "file.txt"), 0644, "hello") + mustWrite(t, filepath.Join(root, "script.sh"), 0755, "#!/bin/sh\n") + mustMkdir(t, filepath.Join(root, "subdir"), 0755) + mustWrite(t, filepath.Join(root, "subdir", "nested.txt"), 0644, "nested") + mustSymlink(t, "../file.txt", filepath.Join(root, "subdir", "link")) + + if err := ChmodRepoTree(root); err != nil { + t.Fatalf("ChmodRepoTree: %v", err) + } + + cases := []struct { + path string + wantMode os.FileMode + }{ + {root, 0770}, + {filepath.Join(root, "file.txt"), 0660}, + {filepath.Join(root, "script.sh"), 0770}, + {filepath.Join(root, "subdir"), 0770}, + {filepath.Join(root, "subdir", "nested.txt"), 0660}, + } + for _, c := range cases { + info, err := os.Stat(c.path) + if err != nil { + t.Errorf("stat %s: %v", c.path, err) + continue + } + if got := info.Mode().Perm(); got != c.wantMode { + t.Errorf("%s: mode = %o, want %o", c.path, got, c.wantMode) + } + } +} + +func TestChmodRepoTree_PreservesExecutableBit(t *testing.T) { + root := t.TempDir() + mustWrite(t, filepath.Join(root, "exec"), 0744, "") + mustWrite(t, filepath.Join(root, "noexec"), 0644, "") + + if err := ChmodRepoTree(root); err != nil { + t.Fatalf("ChmodRepoTree: %v", err) + } + + if got := mode(t, filepath.Join(root, "exec")); got != 0770 { + t.Errorf("exec file: mode = %o, want 0770", got) + } + if got := mode(t, filepath.Join(root, "noexec")); got != 0660 { + t.Errorf("noexec file: mode = %o, want 0660", got) + } +} + +func TestChownRepoTree_SelfChown(t *testing.T) { + // Chowning to our own UID/GID is always a no-op success. This verifies + // the walk visits all entries without erroring. + root := t.TempDir() + mustWrite(t, filepath.Join(root, "a"), 0644, "") + mustMkdir(t, filepath.Join(root, "b"), 0755) + mustWrite(t, filepath.Join(root, "b", "c"), 0644, "") + + uid := os.Getuid() + gid := os.Getgid() + if err := ChownRepoTree(root, uid, gid); err != nil { + t.Fatalf("ChownRepoTree: %v", err) + } + + // verify everything still belongs to us. + for _, p := range []string{root, filepath.Join(root, "a"), filepath.Join(root, "b"), filepath.Join(root, "b", "c")} { + info, err := os.Stat(p) + if err != nil { + t.Fatalf("stat %s: %v", p, err) + } + _ = info + } +} + +func TestLookupUIDForRepoPath(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("uid/gid lookup is unix-only") + } + scan := t.TempDir() + repo := filepath.Join(scan, "did:plc:abc") + mustMkdir(t, repo, 0700) + + uid, gid, err := LookupUIDForRepoPath(scan, repo) + if err != nil { + t.Fatalf("LookupUIDForRepoPath: %v", err) + } + if uid != uint32(os.Getuid()) { + t.Errorf("uid = %d, want %d", uid, os.Getuid()) + } + if gid != uint32(os.Getgid()) { + t.Errorf("gid = %d, want %d", gid, os.Getgid()) + } +} + +func TestLookupUIDForRepoPath_OutsideScanPath(t *testing.T) { + _, _, err := LookupUIDForRepoPath("/home/git", "/etc/passwd") + if err == nil { + t.Fatal("expected error for path outside scan path, got nil") + } +} + +func TestLookupUIDForRepoPath_NonexistentPath(t *testing.T) { + scan := t.TempDir() + _, _, err := LookupUIDForRepoPath(scan, filepath.Join(scan, "does-not-exist")) + if err == nil { + t.Fatal("expected error for nonexistent path, got nil") + } +} + +// helpers + +func mustWrite(t *testing.T, path string, mode os.FileMode, content string) { + t.Helper() + if err := os.WriteFile(path, []byte(content), mode); err != nil { + t.Fatalf("write %s: %v", path, err) + } + // WriteFile respects existing mode on overwrite; chmod to be sure. + if err := os.Chmod(path, mode); err != nil { + t.Fatalf("chmod %s: %v", path, err) + } +} + +func mustMkdir(t *testing.T, path string, mode os.FileMode) { + t.Helper() + if err := os.MkdirAll(path, mode); err != nil { + t.Fatalf("mkdir %s: %v", path, err) + } +} + +func mustSymlink(t *testing.T, target, link string) { + t.Helper() + if err := os.Symlink(target, link); err != nil { + t.Fatalf("symlink %s -> %s: %v", link, target, err) + } +} + +func mode(t *testing.T, path string) os.FileMode { + t.Helper() + info, err := os.Stat(path) + if err != nil { + t.Fatalf("stat %s: %v", path, err) + } + return info.Mode().Perm() +} diff --git a/knotserver/sandbox/sandbox_linux_test.go b/knotserver/sandbox/sandbox_linux_test.go new file mode 100644 --- /dev/null +++ b/knotserver/sandbox/sandbox_linux_test.go @@ -0,0 +1,175 @@ +//go:build linux + +package sandbox + +import ( + "os/exec" + "strings" + "syscall" + "testing" +) + +func TestLandlockBackend_Name(t *testing.T) { + if (&LandlockBackend{}).Name() != "landlock" { + t.Error("Name should return \"landlock\"") + } +} + +func TestLandlockBackend_WrapMulti_NoPaths(t *testing.T) { + sb := &LandlockBackend{selfExe: "/proc/self/exe"} + cmd := exec.Command("git", "status") + + wrapped, err := sb.WrapMulti(nil, cmd) + if err != nil { + t.Fatalf("WrapMulti: %v", err) + } + if wrapped != cmd { + t.Error("empty paths should return the original cmd unchanged") + } +} + +func TestLandlockBackend_WrapMulti_ArgsConstruction(t *testing.T) { + sb := &LandlockBackend{selfExe: "/path/to/knot"} + cmd := exec.Command("git", "upload-pack", "--stateless-rpc", ".") + cmd.Env = []string{"GIT_PROTOCOL=version=2"} + cmd.Stdin = strings.NewReader("input") + + wrapped, err := sb.WrapMulti([]string{"/repos/a", "/repos/b"}, cmd) + if err != nil { + t.Fatalf("WrapMulti: %v", err) + } + + // argv[0] is the selfExe + if wrapped.Path != "/path/to/knot" { + t.Errorf("Path = %q, want %q", wrapped.Path, "/path/to/knot") + } + + // argv should be: [knot, sandbox-exec, --repo-path=/repos/a, --repo-path=/repos/b, --, , upload-pack, ...] + args := wrapped.Args + if len(args) < 6 { + t.Fatalf("Args too short: %v", args) + } + if args[0] != "/path/to/knot" { + t.Errorf("Args[0] = %q, want %q", args[0], "/path/to/knot") + } + if args[1] != "sandbox-exec" { + t.Errorf("Args[1] = %q, want %q", args[1], "sandbox-exec") + } + if args[2] != "--repo-path=/repos/a" { + t.Errorf("Args[2] = %q, want --repo-path=/repos/a", args[2]) + } + if args[3] != "--repo-path=/repos/b" { + t.Errorf("Args[3] = %q, want --repo-path=/repos/b", args[3]) + } + if args[4] != "--" { + t.Errorf("Args[4] = %q, want --", args[4]) + } + // args[5] is the resolved absolute git path; just check it ends with /git + if !strings.HasSuffix(args[5], "/git") && args[5] != "git" { + t.Errorf("Args[5] = %q, want path ending in /git or bare \"git\"", args[5]) + } + if got := args[len(args)-1]; got != "." { + t.Errorf("last arg = %q, want %q", got, ".") + } + + // Dir should be the first repo path so the kernel chdirs there + // after setuid, before execve. + if wrapped.Dir != "/repos/a" { + t.Errorf("Dir = %q, want %q", wrapped.Dir, "/repos/a") + } + + // Env propagated + if len(wrapped.Env) != 1 || wrapped.Env[0] != "GIT_PROTOCOL=version=2" { + t.Errorf("Env = %v, want [GIT_PROTOCOL=version=2]", wrapped.Env) + } + + // Stdio propagated + if wrapped.Stdin != cmd.Stdin { + t.Error("Stdin not propagated to wrapped cmd") + } +} + +func TestLandlockBackend_WrapMulti_NoLookupNoCredential(t *testing.T) { + sb := &LandlockBackend{selfExe: "/path/to/knot"} // no lookup + cmd := exec.Command("git", "status") + + wrapped, err := sb.WrapMulti([]string{"/repos/a"}, cmd) + if err != nil { + t.Fatalf("WrapMulti: %v", err) + } + if wrapped.SysProcAttr != nil && wrapped.SysProcAttr.Credential != nil { + t.Error("no lookup configured; Credential should not be set") + } +} + +func TestLandlockBackend_WrapMulti_LookupSetsCredential(t *testing.T) { + // lookup deliberately returns a different gid (the service group) to + // confirm that WrapMulti ignores it and uses uid as the primary gid. + // see the comment in sandbox_linux.go for why this matters. + sb := &LandlockBackend{ + selfExe: "/path/to/knot", + lookup: func(repoPath string) (uint32, uint32, error) { + if repoPath != "/repos/a" { + t.Errorf("lookup called with %q, want /repos/a", repoPath) + } + return 100042, 1234, nil + }, + } + cmd := exec.Command("git", "status") + + wrapped, err := sb.WrapMulti([]string{"/repos/a"}, cmd) + if err != nil { + t.Fatalf("WrapMulti: %v", err) + } + if wrapped.SysProcAttr == nil || wrapped.SysProcAttr.Credential == nil { + t.Fatal("Credential should be set when lookup returns uid > 0") + } + cred := wrapped.SysProcAttr.Credential + if cred.Uid != 100042 { + t.Errorf("Credential.Uid = %d, want 100042", cred.Uid) + } + if cred.Gid != 100042 { + t.Errorf("Credential.Gid = %d, want 100042 (must equal Uid, not lookup's gid 1234)", cred.Gid) + } + if !cred.NoSetGroups { + t.Error("NoSetGroups should be true") + } +} + +func TestLandlockBackend_WrapMulti_LookupErrSkipsCredential(t *testing.T) { + sb := &LandlockBackend{ + selfExe: "/path/to/knot", + lookup: func(string) (uint32, uint32, error) { + return 0, 0, syscall.ENOENT + }, + } + cmd := exec.Command("git", "status") + + wrapped, err := sb.WrapMulti([]string{"/repos/a"}, cmd) + if err != nil { + t.Fatalf("WrapMulti: %v", err) + } + if wrapped.SysProcAttr != nil && wrapped.SysProcAttr.Credential != nil { + t.Error("lookup errored; Credential should not be set") + } +} + +func TestLandlockBackend_WrapMulti_LookupZeroSkipsCredential(t *testing.T) { + // uid == 0 is treated as "don't drop" so we never accidentally drop to + // root. Verify Credential isn't set in that case. + sb := &LandlockBackend{ + selfExe: "/path/to/knot", + lookup: func(string) (uint32, uint32, error) { + return 0, 0, nil + }, + } + cmd := exec.Command("git", "status") + + wrapped, err := sb.WrapMulti([]string{"/repos/a"}, cmd) + if err != nil { + t.Fatalf("WrapMulti: %v", err) + } + if wrapped.SysProcAttr != nil && wrapped.SysProcAttr.Credential != nil { + t.Error("lookup returned uid=0; Credential should not be set") + } +} diff --git a/knotserver/sandbox/sandbox_test.go b/knotserver/sandbox/sandbox_test.go new file mode 100644 --- /dev/null +++ b/knotserver/sandbox/sandbox_test.go @@ -0,0 +1,55 @@ +package sandbox + +import ( + "os/exec" + "testing" +) + +func TestNoopBackend_Wrap(t *testing.T) { + sb := &NoopBackend{} + cmd := exec.Command("git", "status") + + wrapped, err := sb.Wrap("/some/repo", cmd) + if err != nil { + t.Fatalf("Wrap: %v", err) + } + if wrapped != cmd { + t.Error("Wrap should return the same cmd, not a new one") + } + if wrapped.Dir != "/some/repo" { + t.Errorf("Dir = %q, want %q", wrapped.Dir, "/some/repo") + } +} + +func TestNoopBackend_WrapMulti(t *testing.T) { + sb := &NoopBackend{} + cmd := exec.Command("git", "merge") + + wrapped, err := sb.WrapMulti([]string{"/a", "/b"}, cmd) + if err != nil { + t.Fatalf("WrapMulti: %v", err) + } + if wrapped.Dir != "/a" { + t.Errorf("Dir = %q, want %q (first path)", wrapped.Dir, "/a") + } +} + +func TestNoopBackend_WrapMulti_Empty(t *testing.T) { + sb := &NoopBackend{} + cmd := exec.Command("git", "status") + cmd.Dir = "/preserved" + + wrapped, err := sb.WrapMulti(nil, cmd) + if err != nil { + t.Fatalf("WrapMulti: %v", err) + } + if wrapped.Dir != "/preserved" { + t.Errorf("empty paths should not overwrite cmd.Dir; got %q", wrapped.Dir) + } +} + +func TestNoopBackend_Name(t *testing.T) { + if (&NoopBackend{}).Name() != "noop" { + t.Error("Name should return \"noop\"") + } +} -- tangled.sh