Commit 7e3afe5f90

7e3afe5f90fd87f71ac044c4a1d4bc72d0427b34

parent: 01ae5cf9a0

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-29 00:07 UTC

gitpin: resolve, check and pin a git remote

The mirror worker's resolve-check-pin, as a package import can share.

Ref #298

Layout: unified · split

internal/gitpin/gitpin.go added +127
@@ -0,0 +1,127 @@
1// Package gitpin runs git against a user-supplied http or https remote
2// only at addresses resolved and checked immediately before: mirror
3// sync (#279) and repo import (#298).
4package gitpin
5
6import (
7 "context"
8 "fmt"
9 "net"
10 "net/url"
11 "os/exec"
12 "strconv"
13 "strings"
14
15 "gitbay.org/gitbay/internal/toolpath"
16 "gitbay.org/gitbay/internal/webhook"
17)
18
19// Lookup resolves a host to its addresses.
20type Lookup func(ctx context.Context, host string) ([]net.IP, error)
21
22// LookupIP is the system resolver.
23func LookupIP(ctx context.Context, host string) ([]net.IP, error) {
24 return net.DefaultResolver.LookupIP(ctx, "ip", host)
25}
26
27// Remote is a URL whose host resolved to IPs, every one of which passed
28// the address check.
29type Remote struct {
30 URL *url.URL
31 IPs []net.IP
32}
33
34// Resolve parses raw, requires http or https, resolves the host with
35// lookup, and refuses it when it resolves to nothing or, unless
36// allowLocal, to any private or local address.
37func Resolve(ctx context.Context, lookup Lookup, raw string, allowLocal bool) (Remote, error) {
38 u, err := url.Parse(raw)
39 if err != nil {
40 return Remote{}, err
41 }
42 if u.Scheme != "https" && u.Scheme != "http" {
43 return Remote{}, fmt.Errorf("URL scheme %q is not http or https", u.Scheme)
44 }
45 host := u.Hostname()
46 if host == "" {
47 return Remote{}, fmt.Errorf("URL has no host")
48 }
49 ips, err := lookup(ctx, host)
50 if err != nil {
51 return Remote{}, fmt.Errorf("resolving %s: %w", host, err)
52 }
53 if len(ips) == 0 {
54 // An empty resolve list would leave curl to resolve the host itself.
55 return Remote{}, fmt.Errorf("%s resolves to no address", host)
56 }
57 if err := webhook.CheckAddrs(host, ips, allowLocal); err != nil {
58 return Remote{}, err
59 }
60 return Remote{URL: u, IPs: ips}, nil
61}
62
63// Args are git's leading -c options for r: curl's resolve list pins
64// the host to the checked addresses, and with redirects off a server
65// cannot send git on to a host nobody checked. An address literal
66// needs no pin.
67func (r Remote) Args() []string {
68 args := []string{"-c", "http.followRedirects=false"}
69 host := r.URL.Hostname()
70 if net.ParseIP(host) != nil {
71 return args
72 }
73 port := r.URL.Port()
74 if port == "" {
75 port = "443"
76 if r.URL.Scheme == "http" {
77 port = "80"
78 }
79 }
80 addrs := make([]string, len(r.IPs))
81 for i, ip := range r.IPs {
82 if ip.To4() == nil {
83 addrs[i] = "[" + ip.String() + "]"
84 } else {
85 addrs[i] = ip.String()
86 }
87 }
88 return append(args, "-c", "http.curloptResolve="+host+":"+port+":"+strings.Join(addrs, ","))
89}
90
91// Env is git's whole environment for a pinned remote. No system or
92// global gitconfig: a proxy, URL rewrite or redirect setting there
93// would take git around the pin.
94func Env(home string) []string {
95 return []string{"GIT_TERMINAL_PROMPT=0", "HOME=" + home,
96 "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null"}
97}
98
99// VersionOK accepts the output of `git version` for git 2.37 or later,
100// the first release with http.curloptResolve. An older git ignores the
101// setting and would resolve the host itself.
102func VersionOK(out string) error {
103 fields := strings.Fields(out)
104 if len(fields) >= 3 && fields[0] == "git" && fields[1] == "version" {
105 parts := strings.Split(fields[2], ".")
106 if len(parts) >= 2 {
107 major, err1 := strconv.Atoi(parts[0])
108 minor, err2 := strconv.Atoi(parts[1])
109 if err1 == nil && err2 == nil {
110 if major > 2 || major == 2 && minor >= 37 {
111 return nil
112 }
113 return fmt.Errorf("git %s is older than 2.37 and cannot pin remote addresses", fields[2])
114 }
115 }
116 }
117 return fmt.Errorf("cannot read git version from %q", strings.TrimSpace(out))
118}
119
120// CheckGit runs the server's git and refuses one that cannot pin.
121func CheckGit(ctx context.Context) error {
122 out, err := exec.CommandContext(ctx, toolpath.Look("git"), "version").Output()
123 if err != nil {
124 return fmt.Errorf("running git version: %v", err)
125 }
126 return VersionOK(string(out))
127}
internal/gitpin/gitpin_test.go added +88
@@ -0,0 +1,88 @@
1package gitpin
2
3import (
4 "context"
5 "net"
6 "net/url"
7 "slices"
8 "strings"
9 "testing"
10)
11
12func answer(ips ...string) Lookup {
13 return func(context.Context, string) ([]net.IP, error) {
14 var out []net.IP
15 for _, s := range ips {
16 out = append(out, net.ParseIP(s))
17 }
18 return out, nil
19 }
20}
21
22func TestResolve(t *testing.T) {
23 ctx := context.Background()
24 r, err := Resolve(ctx, answer("203.0.113.5"), "https://git.example/x.git", false)
25 if err != nil || r.URL.Hostname() != "git.example" || len(r.IPs) != 1 {
26 t.Fatalf("public: %+v %v", r, err)
27 }
28 if _, err := Resolve(ctx, answer("203.0.113.5", "10.0.0.7"), "https://git.example/x.git", false); err == nil || !strings.Contains(err.Error(), "10.0.0.7") {
29 t.Fatalf("private: %v", err)
30 }
31 if _, err := Resolve(ctx, answer("10.0.0.7"), "https://git.example/x.git", true); err != nil {
32 t.Fatalf("allow_local: %v", err)
33 }
34 // An empty resolve list would leave curl to resolve the host itself.
35 if _, err := Resolve(ctx, answer(), "https://git.example/x.git", true); err == nil || !strings.Contains(err.Error(), "no address") {
36 t.Fatalf("empty answer: %v", err)
37 }
38 for _, raw := range []string{"git://git.example/x.git", "ssh://git.example/x.git", "file:///etc"} {
39 _, err := Resolve(ctx, func(context.Context, string) ([]net.IP, error) {
40 t.Fatalf("looked up a host for %s", raw)
41 return nil, nil
42 }, raw, true)
43 if err == nil || !strings.Contains(err.Error(), "not http or https") {
44 t.Errorf("%s: %v", raw, err)
45 }
46 }
47}
48
49func TestArgs(t *testing.T) {
50 u, _ := url.Parse("https://git.example/x.git")
51 got := Remote{u, []net.IP{net.ParseIP("203.0.113.5"), net.ParseIP("2001:db8::1")}}.Args()
52 want := []string{"-c", "http.followRedirects=false",
53 "-c", "http.curloptResolve=git.example:443:203.0.113.5,[2001:db8::1]"}
54 if !slices.Equal(got, want) {
55 t.Fatalf("https: %q", got)
56 }
57 u, _ = url.Parse("http://git.example:8080/x.git")
58 if got := (Remote{u, []net.IP{net.ParseIP("203.0.113.5")}}).Args(); got[3] != "http.curloptResolve=git.example:8080:203.0.113.5" {
59 t.Fatalf("http with port: %q", got)
60 }
61 // An address literal is its own resolution; there is nothing to pin.
62 u, _ = url.Parse("https://203.0.113.5/x.git")
63 if got := (Remote{u, []net.IP{net.ParseIP("203.0.113.5")}}).Args(); !slices.Equal(got, []string{"-c", "http.followRedirects=false"}) {
64 t.Fatalf("literal: %q", got)
65 }
66}
67
68func TestEnv(t *testing.T) {
69 want := []string{"GIT_TERMINAL_PROMPT=0", "HOME=/srv/gitbay",
70 "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null"}
71 if got := Env("/srv/gitbay"); !slices.Equal(got, want) {
72 t.Fatalf("Env = %q", got)
73 }
74}
75
76func TestVersionOK(t *testing.T) {
77 for _, s := range []string{"git version 2.37.0", "git version 2.47.3", "git version 2.39.5 (Apple Git-154)",
78 "git version 2.45.2.windows.1", "git version 3.0.0\n"} {
79 if err := VersionOK(s); err != nil {
80 t.Errorf("%q: %v", s, err)
81 }
82 }
83 for _, s := range []string{"git version 2.36.9", "git version 1.99.0", "git version 2", "nonsense", ""} {
84 if err := VersionOK(s); err == nil {
85 t.Errorf("%q accepted", s)
86 }
87 }
88}