tinyrelayfile
tinyrelay/tinygit/repair.go/

tinygit/repair.go raw
1package tinygit
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "net"
8 "net/url"
9 "os"
10 "os/exec"
11 "path/filepath"
12 "strconv"
13 "strings"
14)
15
16// AdmitSource enforces the source restrictions from GRASP's Git sync model.
17// Only HTTPS sources are accepted; localhost, private IPs, credentials,
18// query/fragment data, and this relay itself are rejected.
19func (g *GitRelay) AdmitSource(raw string) (string, error) {
20 return g.admitSource(context.Background(), raw, false)
21}
22
23func (g *GitRelay) admitSource(ctx context.Context, raw string, resolve bool) (string, error) {
24 u, err := url.Parse(raw)
25 privatePeer := g.privatePeer(raw)
26 if err != nil || (u.Scheme != "https" && !(privatePeer && u.Scheme == "http")) || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" {
27 return "", errors.New("invalid: Git source must be a credential-free HTTPS URL")
28 }
29 host := strings.ToLower(u.Hostname())
30 if host == "localhost" || strings.HasSuffix(host, ".localhost") || host == "metadata.google.internal" {
31 return "", errors.New("blocked: Git source host is local or address-like")
32 }
33 if ip := net.ParseIP(host); ip != nil && !g.allowPrivate {
34 return "", errors.New("blocked: Git source host is an IP literal")
35 }
36 if strings.HasSuffix(host, ".internal") || strings.HasSuffix(host, ".local") {
37 return "", errors.New("blocked: Git source host is private")
38 }
39 if resolve {
40 ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host)
41 if err != nil {
42 return "", fmt.Errorf("Git source DNS lookup: %w", err)
43 }
44 for _, ip := range ips {
45 if privateIP(ip) && !g.allowPrivate {
46 return "", errors.New("blocked: Git source resolves to a private address")
47 }
48 }
49 }
50 if g.serviceURL != "" && strings.TrimRight(raw, "/") == g.serviceURL {
51 return "", errors.New("blocked: Git source points to this relay")
52 }
53 return strings.TrimRight(raw, "/"), nil
54}
55
56func (g *GitRelay) resolvedSource(ctx context.Context, raw string) (string, string, error) {
57 source, err := g.admitSource(ctx, raw, false)
58 if err != nil {
59 return "", "", err
60 }
61 u, err := url.Parse(source)
62 if err != nil {
63 return "", "", err
64 }
65 ips, err := net.DefaultResolver.LookupIP(ctx, "ip", u.Hostname())
66 if err != nil {
67 return "", "", fmt.Errorf("Git source DNS lookup: %w", err)
68 }
69 for _, ip := range ips {
70 if !privateIP(ip) || g.allowPrivate {
71 return source, ip.String(), nil
72 }
73 }
74 return "", "", errors.New("blocked: Git source resolves to a private address")
75}
76
77func privateIP(ip net.IP) bool {
78 if ip == nil {
79 return true
80 }
81 if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
82 return true
83 }
84 return ip.IsUnspecified()
85}
86
87// FetchMissing downloads only the expected refs from an admitted source. Git
88// verifies pack checksums and object connectivity; refs are installed only
89// after every expected object exists locally.
90func (g *GitRelay) FetchMissing(ctx context.Context, r Repository, sources []string, expected map[string]string) error {
91 return g.fetchMissing(ctx, r, sources, expected, 0, 0)
92}
93
94// fetchMissing is shared by ordinary branch repair and the more restrictive
95// PR repair path. A non-zero depth keeps an untrusted PR clone from making the
96// hosted repository walk arbitrary history. maxBytes bounds transfer traffic
97// across all sources and the isolated destination after each fetch. These
98// limits apply only to isolated repositories, never the hosted branch repo.
99func (g *GitRelay) fetchMissing(ctx context.Context, r Repository, sources []string, expected map[string]string, depth int, maxBytes int64) error {
100 if err := g.ensureRepo(r); err != nil {
101 return err
102 }
103 local := r
104 local.Refs = expected
105 if g.stateObjectsPresent(ctx, local) {
106 return g.installFetchedRefs(r, expected)
107 }
108 missing := make(map[string]struct{}, len(expected))
109 var budget *byteBudget
110 if maxBytes > 0 {
111 budget = &byteBudget{}
112 }
113 var lastErr error
114 for ref, oid := range expected {
115 if oid != "" {
116 missing[ref] = struct{}{}
117 }
118 }
119 for _, raw := range sources {
120 if err := ctx.Err(); err != nil {
121 return err
122 }
123 if budget != nil && len(missing) == 0 {
124 break
125 }
126 if budget != nil && budget.used.Load() >= maxBytes {
127 return errRepairByteLimit
128 }
129 // Private object IDs must never be requested from a public source.
130 private := r.Private || g.policyValue().Reads == "members"
131 if private && !g.privatePeer(raw) {
132 lastErr = errors.New("blocked: private Git source is not a configured peer")
133 continue
134 }
135 source, ip, err := g.resolvedSource(ctx, raw)
136 if err != nil {
137 lastErr = err
138 continue
139 }
140 var proxy *privateProxy
141 if peerBase := g.privatePeerBase(source); peerBase != "" {
142 if err := probePrivatePeer(ctx, peerBase, g.allowPrivate); err != nil {
143 continue
144 }
145 g.mu.RLock()
146 signer := g.httpAuth
147 g.mu.RUnlock()
148 proxy, err = newPrivateProxy(ctx, source, signer, g.allowPrivate)
149 if err != nil {
150 continue
151 }
152 defer proxy.Close(context.Background())
153 }
154 fetchSource := source
155 if proxy != nil {
156 fetchSource = proxy.URL()
157 }
158 var budgetProxy *repairProxy
159 if budget != nil {
160 targetIP := ip
161 if proxy != nil {
162 if parsed, parseErr := url.Parse(proxy.URL()); parseErr == nil {
163 targetIP = parsed.Hostname()
164 }
165 }
166 parsedSource, parseErr := url.Parse(fetchSource)
167 if parseErr != nil {
168 lastErr = parseErr
169 continue
170 }
171 targetHost := parsedSource.Hostname()
172 targetPort := parsedSource.Port()
173 if targetPort == "" {
174 targetPort = "443"
175 if parsedSource.Scheme == "http" {
176 targetPort = "80"
177 }
178 }
179 budgetProxy, err = newRepairProxy(ctx, targetIP, targetHost, targetPort, budget, maxBytes)
180 if err != nil {
181 lastErr = err
182 continue
183 }
184 defer budgetProxy.Close()
185 }
186 for ref, oid := range expected {
187 if err := ctx.Err(); err != nil {
188 return err
189 }
190 if budget != nil && budget.used.Load() >= maxBytes {
191 return errRepairByteLimit
192 }
193 if _, needed := missing[ref]; budget != nil && !needed {
194 continue
195 }
196 if oid == "" {
197 delete(missing, ref)
198 continue
199 }
200 port := uPort(source)
201 resolve := []string{"-c", "http.curloptResolve=" + urlHost(source) + ":" + port + ":" + ip}
202 if proxy != nil {
203 fetchSource = proxy.URL()
204 resolve = nil
205 }
206 if budgetProxy != nil {
207 resolve = nil
208 }
209 fetchRef := ref
210 if strings.HasPrefix(ref, "refs/nostr/") {
211 // NIP-34 clone URLs may point to ordinary Git hosts. The
212 // signed commit is the source; refs/nostr is our local name.
213 if !isObjectID(oid) {
214 return errors.New("invalid: PR commit ID")
215 }
216 fetchRef = oid
217 }
218 proxySetting := ""
219 if budgetProxy != nil {
220 proxySetting = budgetProxy.URL()
221 }
222 args := []string{"-c", "http.followRedirects=false", "-c", "credential.helper=", "-c", "http.proxy=" + proxySetting, "--git-dir", g.repoPath(r), "fetch", "--no-tags"}
223 if depth > 0 {
224 args = append(args, "--depth", strconv.Itoa(depth))
225 }
226 args = append(args, fetchSource, "+"+fetchRef+":"+"refs/tinyrelay/source/"+safeRef(ref))
227 // Keep the resolve option before the subcommand; Git accepts config
228 // options only before the command name.
229 if len(resolve) > 0 {
230 args = append([]string{"-c", "http.curloptResolve=" + urlHost(source) + ":" + port + ":" + ip}, args...)
231 }
232 cmd := exec.CommandContext(ctx, "git", args...)
233 if depth > 0 && maxBytes > 0 {
234 // Git receives pack data in a child process and does not expose a
235 // portable receive-byte limit. Apply the shell's file-size limit
236 // to the isolated fetch process so an oversized pack is stopped
237 // while it is being written. The unit is 512 bytes in dash and
238 // 1024 in bash, so this is a coarse guard; the proxy budget and
239 // the cumulative size check below enforce the exact bound.
240 limitBlocks := (maxBytes + 511) / 512
241 limited := []string{"ulimit -f \"$1\"; shift; exec \"$@\"", "gitfetch", strconv.FormatInt(limitBlocks, 10), "git"}
242 limited = append(limited, args...)
243 cmd = exec.CommandContext(ctx, "sh", append([]string{"-c"}, limited...)...)
244 }
245 cmd.Env = append(os.Environ(), "HTTP_PROXY=", "HTTPS_PROXY=", "ALL_PROXY=", "http_proxy=", "https_proxy=", "all_proxy=", "NO_PROXY=", "no_proxy=", "GIT_CONFIG_NOSYSTEM=1")
246 if budget != nil {
247 // A URL-specific proxy or rewrite in the user's Git config must
248 // not bypass the bounded transport selected for this repair.
249 cmd.Env = append(cmd.Env, "GIT_CONFIG_GLOBAL="+os.DevNull)
250 }
251 if out, err := cmd.CombinedOutput(); err != nil {
252 lastErr = fmt.Errorf("fetch %s: %w: %s", fetchSource, err, strings.TrimSpace(string(out)))
253 continue
254 }
255 if maxBytes > 0 {
256 size, err := gitDirSize(g.repoPath(r))
257 if err != nil {
258 lastErr = fmt.Errorf("inspect isolated Git repository: %w", err)
259 continue
260 }
261 if size > maxBytes {
262 return fmt.Errorf("Git source exceeded isolated repair budget: %d bytes", size)
263 }
264 }
265 if !g.objectsPresent(ctx, r, []string{oid}) {
266 continue
267 }
268 delete(missing, ref)
269 }
270 }
271 if len(missing) != 0 {
272 if lastErr != nil {
273 return fmt.Errorf("Git sources did not provide every expected object: %w", lastErr)
274 }
275 return errors.New("Git sources did not provide every expected object")
276 }
277 return g.installFetchedRefs(r, expected)
278}
279
280func (g *GitRelay) installFetchedRefs(r Repository, expected map[string]string) error {
281 native, err := g.nativeRefs(r)
282 if err != nil {
283 return err
284 }
285 for ref, oid := range expected {
286 if oid != "" && native[ref] != oid {
287 if err := g.updateRef(r, ref, oid); err != nil {
288 return err
289 }
290 }
291 }
292 return nil
293}
294
295func urlHost(raw string) string {
296 u, _ := url.Parse(raw)
297 h := u.Hostname()
298 if strings.Contains(h, ":") {
299 return "[" + h + "]"
300 }
301 return h
302}
303func uPort(raw string) string {
304 u, _ := url.Parse(raw)
305 if p := u.Port(); p != "" {
306 return p
307 }
308 return "443"
309}
310
311func safeRef(ref string) string {
312 return strings.NewReplacer("/", "_", "\\", "_").Replace(strings.TrimPrefix(ref, "refs/"))
313}
314
315func gitDirSize(root string) (int64, error) {
316 var total int64
317 err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error {
318 if err != nil {
319 if os.IsNotExist(err) {
320 return nil
321 }
322 return err
323 }
324 if entry.IsDir() {
325 return nil
326 }
327 info, err := entry.Info()
328 if err != nil {
329 if os.IsNotExist(err) {
330 return nil
331 }
332 return err
333 }
334 total += info.Size()
335 return nil
336 })
337 return total, err
338}
339
340func (g *GitRelay) updateRef(r Repository, ref, oid string) error {
341 cmd := exec.Command("git", "--git-dir", g.repoPath(r), "update-ref", ref, oid)
342 if out, err := cmd.CombinedOutput(); err != nil {
343 return fmt.Errorf("update ref %s: %w: %s", ref, err, strings.TrimSpace(string(out)))
344 }
345 return nil
346}
347func isObjectID(s string) bool {
348 if len(s) != 40 {
349 return false
350 }
351 for _, c := range s {
352 if !(c >= '0' && c <= '9' || c >= 'a' && c <= 'f') {
353 return false
354 }
355 }
356 return true
357}
358func contains(values []string, want string) bool {
359 for _, value := range values {
360 if value == want {
361 return true
362 }
363 }
364 return false
365}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs