| 1 | package sandbox |
| 2 | |
| 3 | import ( |
| 4 | "errors" |
| 5 | "fmt" |
| 6 | "os" |
| 7 | "path/filepath" |
| 8 | "strings" |
| 9 | ) |
| 10 | |
| 11 | // GitMetadataLinkedCode identifies a refusal to run confined because a |
| 12 | // protected Git metadata file has another hard link the sandbox cannot cover. |
| 13 | const GitMetadataLinkedCode = "sandbox.git_metadata_linked" |
| 14 | |
| 15 | // ErrGitMetadataLinked is returned by CheckGitMetadata for that refusal. |
| 16 | var ErrGitMetadataLinked = errors.New(GitMetadataLinkedCode) |
| 17 | |
| 18 | const ( |
| 19 | // gitGroupMaxEntries bounds the worktree or submodule gitdirs given exact |
| 20 | // rules. Past it Seatbelt falls back to its patterns, so the profile cannot |
| 21 | // be grown by planting gitdirs. |
| 22 | gitGroupMaxEntries = 128 |
| 23 | // gitGroupMaxMounts is the larger budget bubblewrap, which has no |
| 24 | // patterns, spends on exact mounts before binding a whole group read-only. |
| 25 | gitGroupMaxMounts = 512 |
| 26 | gitGroupMaxDirs = 4096 |
| 27 | gitGroupMaxDepth = 8 |
| 28 | gitHooksMaxEntries = 256 |
| 29 | ) |
| 30 | |
| 31 | // gitGroups is the metadata under a common dir's worktrees/ and modules/: |
| 32 | // exact paths for each gitdir found within the limit, and whether a group |
| 33 | // went past it. A group that is missing or not a real directory is skipped. |
| 34 | type gitGroups struct { |
| 35 | Paths []gitProtectedPath |
| 36 | Worktrees string |
| 37 | Modules string |
| 38 | WorktreesOver bool |
| 39 | ModulesOver bool |
| 40 | } |
| 41 | |
| 42 | func gitGroupsOf(common string, limit int) gitGroups { |
| 43 | var g gitGroups |
| 44 | if dir := filepath.Join(common, gitWorktreesSubdir); isRealDir(dir) { |
| 45 | g.Worktrees = dir |
| 46 | entries, _ := os.ReadDir(dir) |
| 47 | n := 0 |
| 48 | for _, e := range entries { |
| 49 | if !e.IsDir() { |
| 50 | continue |
| 51 | } |
| 52 | if n++; n > limit { |
| 53 | g.WorktreesOver = true |
| 54 | break |
| 55 | } |
| 56 | entry := filepath.Join(dir, e.Name()) |
| 57 | g.Paths = append(g.Paths, |
| 58 | gitProtectedPath{Path: entry, Pin: true}, |
| 59 | gitProtectedPath{Path: filepath.Join(entry, "config")}, |
| 60 | gitProtectedPath{Path: filepath.Join(entry, "config.worktree")}, |
| 61 | gitProtectedPath{Path: filepath.Join(entry, "commondir")}) |
| 62 | } |
| 63 | } |
| 64 | if dir := filepath.Join(common, gitModulesSubdir); isRealDir(dir) { |
| 65 | g.Modules = dir |
| 66 | walk := moduleWalk{limit: limit} |
| 67 | walk.visit(dir, 0) |
| 68 | g.ModulesOver = walk.overflow |
| 69 | seen := map[string]bool{} |
| 70 | for _, gitDir := range walk.gitDirs { |
| 71 | for d := gitDir; d != dir && !seen[d]; d = filepath.Dir(d) { |
| 72 | seen[d] = true |
| 73 | g.Paths = append(g.Paths, gitProtectedPath{Path: d, Pin: true}) |
| 74 | } |
| 75 | g.Paths = append(g.Paths, gitDirMetadata(gitDir)[1:]...) |
| 76 | } |
| 77 | } |
| 78 | return g |
| 79 | } |
| 80 | |
| 81 | // gitGroupPaths is gitGroupsOf with each over-limit group protected as one |
| 82 | // read-only tree, for callers that have no patterns to fall back on. |
| 83 | func gitGroupPaths(common string, limit int) []gitProtectedPath { |
| 84 | g := gitGroupsOf(common, limit) |
| 85 | if g.WorktreesOver { |
| 86 | g.Paths = append(g.Paths, gitProtectedPath{Path: g.Worktrees, Tree: true}) |
| 87 | } |
| 88 | if g.ModulesOver { |
| 89 | g.Paths = append(g.Paths, gitProtectedPath{Path: g.Modules, Tree: true}) |
| 90 | } |
| 91 | return g.Paths |
| 92 | } |
| 93 | |
| 94 | func isRealDir(path string) bool { |
| 95 | info, err := os.Lstat(path) |
| 96 | return err == nil && info.IsDir() |
| 97 | } |
| 98 | |
| 99 | // moduleWalk finds submodule git directories, recognised by their HEAD file as |
| 100 | // git recognises a git directory, within a fixed budget. Entries are read |
| 101 | // without following symlinks, so a planted link cannot lead the walk away. |
| 102 | type moduleWalk struct { |
| 103 | limit int |
| 104 | gitDirs []string |
| 105 | dirs int |
| 106 | overflow bool |
| 107 | } |
| 108 | |
| 109 | func (w *moduleWalk) visit(dir string, depth int) { |
| 110 | if w.overflow || depth > gitGroupMaxDepth { |
| 111 | return |
| 112 | } |
| 113 | entries, err := os.ReadDir(dir) |
| 114 | if err != nil { |
| 115 | return |
| 116 | } |
| 117 | for _, e := range entries { |
| 118 | if !e.IsDir() { |
| 119 | continue |
| 120 | } |
| 121 | if w.dirs++; w.dirs > gitGroupMaxDirs { |
| 122 | w.overflow = true |
| 123 | return |
| 124 | } |
| 125 | sub := filepath.Join(dir, e.Name()) |
| 126 | if info, err := os.Lstat(filepath.Join(sub, "HEAD")); err == nil && info.Mode().IsRegular() { |
| 127 | if len(w.gitDirs) >= w.limit { |
| 128 | w.overflow = true |
| 129 | return |
| 130 | } |
| 131 | w.gitDirs = append(w.gitDirs, sub) |
| 132 | w.visit(filepath.Join(sub, gitModulesSubdir), depth+1) |
| 133 | continue |
| 134 | } |
| 135 | w.visit(sub, depth+1) |
| 136 | } |
| 137 | } |
| 138 | |
| 139 | // CheckGitMetadata refuses a confined launch when a protected file has another |
| 140 | // hard link: a write through that name would change the file and no path |
| 141 | // rule can see it. Such a link predates the sandbox, which refuses making one. |
| 142 | func CheckGitMetadata(spec Spec) error { |
| 143 | linked := gitMetadataLinked(spec) |
| 144 | if len(linked) == 0 { |
| 145 | return nil |
| 146 | } |
| 147 | return fmt.Errorf("%w: %s has another hard link, so a write through that link would change host-protected Git metadata; "+ |
| 148 | "the user has to remove the extra link outside the sandbox (find the workspace -samefile <path>) before commands run confined here", |
| 149 | ErrGitMetadataLinked, strings.Join(linked, ", ")) |
| 150 | } |
| 151 | |
| 152 | func gitMetadataLinked(spec Spec) []string { |
| 153 | meta := gitMetadataForSpec(spec) |
| 154 | paths := meta.Paths |
| 155 | for _, common := range meta.Commons { |
| 156 | paths = append(paths, gitGroupsOf(common, gitGroupMaxEntries).Paths...) |
| 157 | } |
| 158 | var linked []string |
| 159 | for _, p := range paths { |
| 160 | switch { |
| 161 | case p.Pin: |
| 162 | case p.Tree: |
| 163 | linked = append(linked, linkedFilesUnder(p.Path)...) |
| 164 | case isLinkedFile(p.Path): |
| 165 | linked = append(linked, p.Path) |
| 166 | } |
| 167 | } |
| 168 | return linked |
| 169 | } |
| 170 | |
| 171 | func linkedFilesUnder(root string) []string { |
| 172 | var out []string |
| 173 | seen := 0 |
| 174 | _ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { |
| 175 | if err != nil { |
| 176 | return nil |
| 177 | } |
| 178 | if seen++; seen > gitHooksMaxEntries { |
| 179 | return filepath.SkipAll |
| 180 | } |
| 181 | if !d.IsDir() && isLinkedFile(path) { |
| 182 | out = append(out, path) |
| 183 | } |
| 184 | return nil |
| 185 | }) |
| 186 | return out |
| 187 | } |
| 188 | |
| 189 | func isLinkedFile(path string) bool { |
| 190 | info, err := os.Lstat(path) |
| 191 | return err == nil && info.Mode().IsRegular() && linkCount(info) > 1 |
| 192 | } |
| 193 |