| 1 | package sandbox |
| 2 | |
| 3 | import ( |
| 4 | "os" |
| 5 | "path/filepath" |
| 6 | "slices" |
| 7 | "strings" |
| 8 | ) |
| 9 | |
| 10 | // GitMetadataDeniedCode identifies a write the sandbox refused because the |
| 11 | // path is Git configuration or hooks that the host's own git reads. |
| 12 | const GitMetadataDeniedCode = "sandbox.git_metadata_protected" |
| 13 | |
| 14 | const ( |
| 15 | gitFileMaxBytes = 4096 |
| 16 | gitSymlinkMaxHops = 40 |
| 17 | gitWorktreesSubdir = "worktrees" |
| 18 | gitModulesSubdir = "modules" |
| 19 | ) |
| 20 | |
| 21 | // gitProtectedPath is one host-protected Git metadata path. A tree covers |
| 22 | // everything beneath it; otherwise only the exact entry is protected. A pin |
| 23 | // only holds an entry in place: it cannot be removed, renamed or replaced. |
| 24 | type gitProtectedPath struct { |
| 25 | Path string |
| 26 | Tree bool |
| 27 | Pin bool |
| 28 | } |
| 29 | |
| 30 | // gitMetadata is what a spec protects. Commons are common directories whose |
| 31 | // worktrees/ and modules/ hold further metadata, covered per backend. |
| 32 | type gitMetadata struct { |
| 33 | Paths []gitProtectedPath |
| 34 | Commons []string |
| 35 | } |
| 36 | |
| 37 | // GitMetadataPaths lists the Git configuration, pointer and hook paths a |
| 38 | // confined command may not write, as absolute symlink-resolved paths. Hook |
| 39 | // trees end in a separator. Entries that are only pinned in place are omitted. |
| 40 | func GitMetadataPaths(spec Spec) []string { |
| 41 | meta := gitMetadataForSpec(spec) |
| 42 | paths := meta.Paths |
| 43 | for _, common := range meta.Commons { |
| 44 | paths = append(paths, gitGroupPaths(common, gitGroupMaxEntries)...) |
| 45 | } |
| 46 | var out []string |
| 47 | for _, p := range paths { |
| 48 | switch { |
| 49 | case p.Pin: |
| 50 | case p.Tree: |
| 51 | out = append(out, p.Path+string(filepath.Separator)) |
| 52 | default: |
| 53 | out = append(out, p.Path) |
| 54 | } |
| 55 | } |
| 56 | return out |
| 57 | } |
| 58 | |
| 59 | func gitMetadataForSpec(spec Spec) gitMetadata { |
| 60 | if !spec.Enforce() || spec.ReadOnly { |
| 61 | return gitMetadata{} |
| 62 | } |
| 63 | writable := writableDirsForSpec(spec) |
| 64 | if len(writable) == 0 { |
| 65 | return gitMetadata{} |
| 66 | } |
| 67 | return gitMetadataWithin(gitMetadataRoots(spec), writable) |
| 68 | } |
| 69 | |
| 70 | // gitMetadataWithin resolves the repository git itself would discover from |
| 71 | // each root and keeps what lies inside writable; the rest is unwritable anyway. |
| 72 | func gitMetadataWithin(roots, writable []string) gitMetadata { |
| 73 | var found []gitProtectedPath |
| 74 | var commons []string |
| 75 | for _, root := range roots { |
| 76 | paths, common := repositoryMetadata(root) |
| 77 | found = append(found, paths...) |
| 78 | if common != "" && withinAny(common, writable) && !slices.Contains(commons, common) { |
| 79 | commons = append(commons, common) |
| 80 | } |
| 81 | } |
| 82 | var out []gitProtectedPath |
| 83 | seen := map[string]bool{} |
| 84 | add := func(p gitProtectedPath) { |
| 85 | if seen[p.Path] || !withinAny(p.Path, writable) { |
| 86 | return |
| 87 | } |
| 88 | seen[p.Path] = true |
| 89 | out = append(out, p) |
| 90 | } |
| 91 | for _, p := range found { |
| 92 | for _, anc := range writableAncestors(p.Path, writable) { |
| 93 | add(gitProtectedPath{Path: anc, Pin: true}) |
| 94 | } |
| 95 | add(p) |
| 96 | } |
| 97 | slices.SortFunc(out, func(a, b gitProtectedPath) int { return strings.Compare(a.Path, b.Path) }) |
| 98 | slices.Sort(commons) |
| 99 | return gitMetadata{Paths: out, Commons: commons} |
| 100 | } |
| 101 | |
| 102 | // repositoryMetadata walks up from root the way git's discovery does and |
| 103 | // returns the config, pointer and hook paths of the first `.git` it meets, |
| 104 | // with the common directory git would use for it. |
| 105 | func repositoryMetadata(root string) ([]gitProtectedPath, string) { |
| 106 | root = strings.TrimSpace(root) |
| 107 | if root == "" { |
| 108 | return nil, "" |
| 109 | } |
| 110 | dir, err := filepath.EvalSymlinks(root) |
| 111 | if err != nil || !filepath.IsAbs(dir) { |
| 112 | return nil, "" |
| 113 | } |
| 114 | for { |
| 115 | entry := filepath.Join(dir, ".git") |
| 116 | info, err := os.Lstat(entry) |
| 117 | if err == nil { |
| 118 | return entryMetadata(dir, entry, info) |
| 119 | } |
| 120 | if !os.IsNotExist(err) { |
| 121 | return nil, "" |
| 122 | } |
| 123 | parent := filepath.Dir(dir) |
| 124 | if parent == dir { |
| 125 | return nil, "" |
| 126 | } |
| 127 | dir = parent |
| 128 | } |
| 129 | } |
| 130 | |
| 131 | func entryMetadata(dir, entry string, info os.FileInfo) ([]gitProtectedPath, string) { |
| 132 | var out []gitProtectedPath |
| 133 | target := entry |
| 134 | if info.Mode().IsRegular() { |
| 135 | out = append(out, gitProtectedPath{Path: entry}) |
| 136 | pointer, ok := readGitPointer(entry, "gitdir: ") |
| 137 | if !ok { |
| 138 | return out, "" |
| 139 | } |
| 140 | target = pointer |
| 141 | if !filepath.IsAbs(target) { |
| 142 | target = filepath.Join(dir, target) |
| 143 | } |
| 144 | } else { |
| 145 | out = append(out, gitProtectedPath{Path: entry, Pin: true}) |
| 146 | if stat, err := os.Stat(entry); err != nil || !stat.IsDir() { |
| 147 | return out, "" |
| 148 | } |
| 149 | } |
| 150 | gitDir, links := resolveWithLinks(target) |
| 151 | out = append(out, pins(links)...) |
| 152 | commonDir := gitDir |
| 153 | if common, ok := readGitPointer(filepath.Join(gitDir, "commondir"), ""); ok { |
| 154 | if !filepath.IsAbs(common) { |
| 155 | common = filepath.Join(gitDir, common) |
| 156 | } |
| 157 | commonDir, links = resolveWithLinks(common) |
| 158 | out = append(out, pins(links)...) |
| 159 | } |
| 160 | out = append(out, gitDirMetadata(gitDir)...) |
| 161 | if commonDir != gitDir { |
| 162 | out = append(out, gitDirMetadata(commonDir)...) |
| 163 | } |
| 164 | out = append(out, |
| 165 | gitProtectedPath{Path: filepath.Join(commonDir, gitWorktreesSubdir), Pin: true}, |
| 166 | gitProtectedPath{Path: filepath.Join(commonDir, gitModulesSubdir), Pin: true}) |
| 167 | return out, commonDir |
| 168 | } |
| 169 | |
| 170 | func gitDirMetadata(gitDir string) []gitProtectedPath { |
| 171 | return []gitProtectedPath{ |
| 172 | {Path: gitDir, Pin: true}, |
| 173 | {Path: filepath.Join(gitDir, "config")}, |
| 174 | {Path: filepath.Join(gitDir, "config.worktree")}, |
| 175 | {Path: filepath.Join(gitDir, "commondir")}, |
| 176 | {Path: filepath.Join(gitDir, "hooks"), Tree: true}, |
| 177 | } |
| 178 | } |
| 179 | |
| 180 | func pins(paths []string) []gitProtectedPath { |
| 181 | out := make([]gitProtectedPath, 0, len(paths)) |
| 182 | for _, p := range paths { |
| 183 | out = append(out, gitProtectedPath{Path: p, Pin: true}) |
| 184 | } |
| 185 | return out |
| 186 | } |
| 187 | |
| 188 | // readGitPointer reads a small regular pointer file. prefix, when set, must |
| 189 | // open the content exactly as git requires of a `.git` file. |
| 190 | func readGitPointer(path, prefix string) (string, bool) { |
| 191 | info, err := os.Stat(path) |
| 192 | if err != nil || !info.Mode().IsRegular() || info.Size() > gitFileMaxBytes { |
| 193 | return "", false |
| 194 | } |
| 195 | data, err := os.ReadFile(path) |
| 196 | if err != nil { |
| 197 | return "", false |
| 198 | } |
| 199 | text := strings.TrimRight(string(data), " \t\r\n") |
| 200 | if prefix != "" { |
| 201 | rest, ok := strings.CutPrefix(text, prefix) |
| 202 | if !ok { |
| 203 | return "", false |
| 204 | } |
| 205 | text = rest |
| 206 | } |
| 207 | text = strings.TrimSpace(text) |
| 208 | return text, text != "" |
| 209 | } |
| 210 | |
| 211 | // resolveWithLinks resolves path the way the kernel will, component by |
| 212 | // component, and returns every symlink it passed through: swapping any of |
| 213 | // them would redirect git without touching a protected path. A missing tail |
| 214 | // is kept lexically, so a path that does not exist yet is still named. |
| 215 | func resolveWithLinks(path string) (string, []string) { |
| 216 | abs, err := filepath.Abs(path) |
| 217 | if err != nil { |
| 218 | return filepath.Clean(path), nil |
| 219 | } |
| 220 | var links []string |
| 221 | pending := splitPath(abs) |
| 222 | cur := string(filepath.Separator) |
| 223 | for hops := 0; len(pending) > 0; { |
| 224 | part := pending[0] |
| 225 | pending = pending[1:] |
| 226 | switch part { |
| 227 | case "", ".": |
| 228 | continue |
| 229 | case "..": |
| 230 | cur = filepath.Dir(cur) |
| 231 | continue |
| 232 | } |
| 233 | next := filepath.Join(cur, part) |
| 234 | info, err := os.Lstat(next) |
| 235 | if err != nil { |
| 236 | return filepath.Join(append([]string{next}, pending...)...), links |
| 237 | } |
| 238 | if info.Mode()&os.ModeSymlink == 0 { |
| 239 | cur = next |
| 240 | continue |
| 241 | } |
| 242 | target, err := os.Readlink(next) |
| 243 | if hops++; err != nil || hops > gitSymlinkMaxHops { |
| 244 | return filepath.Join(append([]string{next}, pending...)...), links |
| 245 | } |
| 246 | links = append(links, next) |
| 247 | if filepath.IsAbs(target) { |
| 248 | cur = string(filepath.Separator) |
| 249 | } |
| 250 | pending = append(splitPath(target), pending...) |
| 251 | } |
| 252 | return cur, links |
| 253 | } |
| 254 | |
| 255 | func splitPath(p string) []string { |
| 256 | return strings.Split(filepath.ToSlash(p), "/") |
| 257 | } |
| 258 | |
| 259 | // writableAncestors are path's ancestors inside a writable directory, outermost |
| 260 | // first. Renaming one away would move the protected path out from under its rule. |
| 261 | func writableAncestors(path string, writable []string) []string { |
| 262 | var out []string |
| 263 | for dir := filepath.Dir(path); ; dir = filepath.Dir(dir) { |
| 264 | if !withinAny(dir, writable) { |
| 265 | break |
| 266 | } |
| 267 | out = append(out, dir) |
| 268 | if filepath.Dir(dir) == dir { |
| 269 | break |
| 270 | } |
| 271 | } |
| 272 | slices.Reverse(out) |
| 273 | return out |
| 274 | } |
| 275 | |
| 276 | func withinAny(path string, dirs []string) bool { |
| 277 | for _, d := range dirs { |
| 278 | if PathWithin(d, path) { |
| 279 | return true |
| 280 | } |
| 281 | } |
| 282 | return false |
| 283 | } |
| 284 |