Monorepo for Tangled
tangled.org
1package sandbox
2
3import (
4 "fmt"
5 "io/fs"
6 "os"
7 "path/filepath"
8 "sort"
9 "strings"
10 "syscall"
11)
12
13// ChmodRepoTree sets directory modes to 0770 and file modes to 0660 under
14// root, preserving the executable bit on files (hook scripts need it).
15// Symlinks are skipped since their mode is not meaningful.
16//
17// The group bits exist so the knot service (running as the git user, which
18// is in the git group that owns the repos) can still read and write the
19// repo via group permissions even though the repo's UID owner is a virtual
20// UID. Sandbox subprocesses run with NoSetGroups: true so they don't gain
21// group access and cross-owner isolation still holds.
22func ChmodRepoTree(root string) error {
23 return filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
24 if err != nil {
25 return err
26 }
27 if d.Type()&fs.ModeSymlink != 0 {
28 return nil
29 }
30 if d.IsDir() {
31 return os.Chmod(path, 0770)
32 }
33 info, err := d.Info()
34 if err != nil {
35 return err
36 }
37 mode := fs.FileMode(0660)
38 if info.Mode()&0100 != 0 {
39 mode = 0770
40 }
41 return os.Chmod(path, mode)
42 })
43}
44
45// ChownRepoTree recursively chowns every entry under root to uid:gid.
46// Entries are processed deepest-first so a directory is only chowned after
47// its contents, preserving the calling process's access throughout the walk.
48// Call ChmodRepoTree first if you also want to tighten permissions; this
49// function only changes ownership.
50func ChownRepoTree(root string, uid int, gid int) error {
51 type entry struct {
52 path string
53 depth int
54 }
55 var entries []entry
56 if err := filepath.WalkDir(root, func(path string, _ fs.DirEntry, err error) error {
57 if err != nil {
58 return err
59 }
60 depth := strings.Count(path, string(filepath.Separator))
61 entries = append(entries, entry{path, depth})
62 return nil
63 }); err != nil {
64 return err
65 }
66
67 sort.Slice(entries, func(i, j int) bool {
68 return entries[i].depth > entries[j].depth
69 })
70
71 for _, e := range entries {
72 if err := os.Lchown(e.path, uid, gid); err != nil {
73 return err
74 }
75 }
76 return nil
77}
78
79// LookupUIDForRepoPath returns the owner UID and GID of the repo directory at
80// repoPath. scanPath is validated as a prefix to guard against directory escape.
81func LookupUIDForRepoPath(scanPath, repoPath string) (uid uint32, gid uint32, err error) {
82 if !strings.HasPrefix(repoPath, scanPath) {
83 return 0, 0, fmt.Errorf("repo path %q is outside scan path %q", repoPath, scanPath)
84 }
85 var stat syscall.Stat_t
86 if err := syscall.Stat(repoPath, &stat); err != nil {
87 return 0, 0, err
88 }
89 return stat.Uid, stat.Gid, nil
90}
91
92// ServiceGid returns the GID of scanPath, which is treated as the "service
93// group" that owns all repositories. Callers chown repo trees to
94// (virtualUID, ServiceGid(scanPath)) so the knot service (a member of this
95// group) retains read+write access via the group bits set by ChmodRepoTree.
96func ServiceGid(scanPath string) (uint32, error) {
97 var stat syscall.Stat_t
98 if err := syscall.Stat(scanPath, &stat); err != nil {
99 return 0, fmt.Errorf("stat %s: %w", scanPath, err)
100 }
101 return stat.Gid, nil
102}