返回 DeepSeek-Reasonix
git_metadata.go
根目录 / internal / sandbox / git_metadata.go
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
284 lines GO