Commit 869d5659fb

869d5659fb7a7e7259e1aa5f3aa4df71b58fa204

parent: 0babae2db0

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-28 08:45 UTC

mirror: check the address before each sync and pin git to it

Closes #279

Layout: unified · split

.gitbay/wiki/API.org +3 −3
@@ -197,6 +197,6 @@ A 2xx within 10 seconds is success. Anything else retries with
197exponential backoff (30s base, doubling) and dead-letters after five 197exponential backoff (30s base, doubling) and dead-letters after five
198attempts; =webhook deliveries= shows the trail and =redeliver= revives a 198attempts; =webhook deliveries= shows the trail and =redeliver= revives a
199dead letter. Redirects are never followed, and targets resolving to 199dead letter. Redirects are never followed, and targets resolving to
200loopback/private/link-local addresses are refused both at registration 200loopback, private, shared (100.64.0.0/10), link-local or multicast
201and again at connect time, unless the instance sets 201addresses are refused both at registration and again at connect time,
202=[webhooks] allow_local=. 202unless the instance sets =[webhooks] allow_local=.
.gitbay/wiki/Admin.org +7 −2
@@ -174,7 +174,8 @@ push=.
174 means no credential-bearing HTTP endpoint exists at all. 174 means no credential-bearing HTTP endpoint exists at all.
175 175
176** [webhooks] 176** [webhooks]
177- =allow_local= (false) — permit webhook targets on loopback/private 177- =allow_local= (false) — permit webhook and mirror targets on
178 loopback, private, shared (100.64.0.0/10), link-local or multicast
178 addresses. Leave off unless you know why you need it (SSRF). 179 addresses. Leave off unless you know why you need it (SSRF).
179 180
180** [limits] 181** [limits]
@@ -198,7 +199,11 @@ push=.
198** [mirrors] 199** [mirrors]
199- =pull_interval_minutes= (15) — how often pull mirrors fetch their 200- =pull_interval_minutes= (15) — how often pull mirrors fetch their
200 upstream. Push mirrors sync shortly after each local ref update. 201 upstream. Push mirrors sync shortly after each local ref update.
201 Mirror URLs pass the same SSRF rules as webhook targets. 202 Mirror URLs pass the same SSRF rules as webhook targets, when saved
203 and again before every sync; git then connects only to the addresses
204 that were checked (=http.curloptResolve=) and does not follow
205 redirects, so a mirror of a renamed repository fails until its URL
206 is updated. Needs git 2.37 or later on the server.
202 207
203** [go_import] 208** [go_import]
204Vanity Go module paths, one per line: ="host/module" = "owner/repo"=. 209Vanity Go module paths, one per line: ="host/module" = "owner/repo"=.
.gitbay/wiki/Architecture/03-Deployment.org +2 −2
@@ -59,8 +59,8 @@ a database check.
59| ACME directory | certificate issue and renewal | yes | host policy limits names to the site and claimed pages domains (=main.go=) | 59| ACME directory | certificate issue and renewal | yes | host policy limits names to the site and claimed pages domains (=main.go=) |
60| SMTP relay | queued mail | STARTTLS required for a non-local relay, or implicit TLS | =mail.require_tls=; Go's =PlainAuth= will not send credentials over plaintext to a non-local host (=internal/mail/mail.go=) | 60| SMTP relay | queued mail | STARTTLS required for a non-local relay, or implicit TLS | =mail.require_tls=; Go's =PlainAuth= will not send credentials over plaintext to a non-local host (=internal/mail/mail.go=) |
61| APNs | queued push | yes, HTTP/2 | provider token signed with the operator's .p8 key | 61| APNs | queued push | yes, HTTP/2 | provider token signed with the operator's .p8 key |
62| Webhook URLs | recorded events | yes when https; certificate verified | private, loopback and link-local targets refused at save and again at connect time; no redirects (=internal/webhook/webhook.go=) | 62| Webhook URLs | recorded events | yes when https; certificate verified | private, shared, loopback, link-local and multicast targets refused at save and again at connect time; no redirects (=internal/webhook/webhook.go=) |
63| Mirror URLs | mirror schedule | per URL | the same address check at save time (=internal/control/mirrorcmd.go=); git makes the connection, so there is no connect-time re-check | 63| Mirror URLs | mirror schedule | per URL | address check at save and before each sync; git pinned to the checked addresses, no redirects (=internal/mirror/mirror.go=) |
64| Package registries | dependency checks | yes | fixed hosts; only the package name varies (=internal/deps/registry.go=) | 64| Package registries | dependency checks | yes | fixed hosts; only the package name varies (=internal/deps/registry.go=) |
65 65
66* Host firewall 66* Host firewall
.gitbay/wiki/Architecture/07-CI-and-Supply-Chain.org +1 −1
@@ -75,7 +75,7 @@ Who may do what:
75| Integration | Trigger | Security properties | 75| Integration | Trigger | Security properties |
76|-------------+----------------------+--------------------------------------------------------------------------------| 76|-------------+----------------------+--------------------------------------------------------------------------------|
77| Webhooks | recorded events | SSRF checks at save and connect time, no redirects, HMAC-SHA256 signature, 5 attempts with exponential backoff, response body capped at 4 KiB (=internal/webhook/webhook.go=) | 77| Webhooks | recorded events | SSRF checks at save and connect time, no redirects, HMAC-SHA256 signature, 5 attempts with exponential backoff, response body capped at 4 KiB (=internal/webhook/webhook.go=) |
78| Mirrors | schedule | address check at save; token via =GIT_ASKPASS= script (0700); heads and tags only; 10-minute timeout (=internal/mirror/mirror.go=) | 78| Mirrors | schedule | address check at save and before each sync, git pinned to the checked addresses, no redirects; token via =GIT_ASKPASS= script (0700); heads and tags only; 10-minute timeout (=internal/mirror/mirror.go=) |
79| Dependency checks | schedule, opt-in | fixed registry hosts; package names restricted (=internal/deps/registry.go=) | 79| Dependency checks | schedule, opt-in | fixed registry hosts; package names restricted (=internal/deps/registry.go=) |
80 80
81* The project's own supply chain 81* The project's own supply chain
.gitbay/wiki/Architecture/09-Controls.org +1 −1
@@ -75,7 +75,7 @@ chapter names of OWASP ASVS 4.0 where one fits.
75 75
76| Control | Status | Evidence | 76| Control | Status | Evidence |
77|---------------------------------------------+----------+------------------------------------------------------------------| 77|---------------------------------------------+----------+------------------------------------------------------------------|
78| SSRF protection on user-supplied URLs | partial | webhooks at save and connect; mirrors at save only (#279) | 78| SSRF protection on user-supplied URLs | in place | webhooks at save and connect; mirrors at save and sync, git pinned to the checked address (=internal/mirror/mirror.go=) |
79| Webhook payload integrity | in place | HMAC-SHA256 header | 79| Webhook payload integrity | in place | HMAC-SHA256 header |
80| SMTP credentials protected in transit | in place | STARTTLS required for non-local relays, implicit TLS optional (=internal/mail/mail.go=) | 80| SMTP credentials protected in transit | in place | STARTTLS required for non-local relays, implicit TLS optional (=internal/mail/mail.go=) |
81| Upload size limits | in place | per-owner storage quota at push (=internal/sshd/sshd.go=); API body 1 MiB | 81| Upload size limits | in place | per-owner storage quota at push (=internal/sshd/sshd.go=); API body 1 MiB |
.gitbay/wiki/Architecture/10-Known-Gaps.org −1
@@ -19,7 +19,6 @@ what the 2026-09-27 review found; remove a row when its issue closes.
19| #273 | Data at rest | CI secrets, webhook secrets and mirror tokens are stored in clear in SQLite | high | 19| #273 | Data at rest | CI secrets, webhook secrets and mirror tokens are stored in clear in SQLite | high |
20| #274 | Backups | The local backup archive is not encrypted | medium | 20| #274 | Backups | The local backup archive is not encrypted | medium |
21| #275 | Audit | Refused writes are not audited; the audit table is writable by the daemon user | medium | 21| #275 | Audit | Refused writes are not audited; the audit table is writable by the daemon user | medium |
22| #279 | SSRF | Mirror URLs are checked when saved, not when git connects | medium |
23| #282 | Hook socket | Anything that can open =hook.sock= can act as any user | medium | 22| #282 | Hook socket | Anything that can open =hook.sock= can act as any user | medium |
24| #297 | Credentials | A browser session can mint tokens and keys that outlive it | low | 23| #297 | Credentials | A browser session can mint tokens and keys that outlive it | low |
25 24
.gitbay/wiki/Threat-Model.org +6 −4
@@ -88,10 +88,12 @@ Anything that makes the *server* open an outbound connection to a
88user-supplied address — webhook delivery, GitHub-history import 88user-supplied address — webhook delivery, GitHub-history import
89=--api-base=, mirror remotes — passes the same SSRF guard: the scheme 89=--api-base=, mirror remotes — passes the same SSRF guard: the scheme
90must be http/https and, unless =webhooks.allow_local= is set, the 90must be http/https and, unless =webhooks.allow_local= is set, the
91resolved address must not be loopback, private, or link-local. The 91resolved address must not be loopback, private, shared
92webhook dialer re-checks at connect time so a DNS answer that changes 92(100.64.0.0/10), link-local, or multicast. The webhook dialer re-checks
93after validation still cannot reach private space. Redirects are never 93at connect time, and the mirror worker resolves and checks before each
94followed. 94sync and pins git to the checked addresses, so a DNS answer that
95changes after validation still cannot reach private space. Redirects
96are never followed.
95 97
96* Rendering pushed markup 98* Rendering pushed markup
97 99
CHANGELOG.org +7
@@ -39,6 +39,13 @@ must add =--scope full=. Existing tokens keep their scope.
39 not offer STARTTLS gets no mail unless =mail.require_tls = false= 39 not offer STARTTLS gets no mail unless =mail.require_tls = false=
40 restores the old behaviour. =mail.tls = "implicit"= speaks TLS from 40 restores the old behaviour. =mail.tls = "implicit"= speaks TLS from
41 the first byte, for relays on port 465 (#280). 41 the first byte, for relays on port 465 (#280).
42- Mirror sync resolves the host, checks the addresses and connects git
43 only to them, with redirects off; a URL that now resolves to private
44 space, or is not http or https, fails the sync with the reason on
45 =repo mirror list=. A mirror of a renamed repository that redirects
46 fails until its URL is updated. Needs git 2.37 or later (#279).
47- Webhook and mirror targets in 100.64.0.0/10 or on a multicast
48 address are refused, as private addresses are (#279).
42 49
43* v1.36.0 — 2026-09-23 50* v1.36.0 — 2026-09-23
44 51
internal/mirror/mirror.go +66 −8
@@ -9,15 +9,19 @@ import (
9 "context" 9 "context"
10 "fmt" 10 "fmt"
11 "log/slog" 11 "log/slog"
12 "net"
13 "net/url"
12 "os" 14 "os"
13 "os/exec" 15 "os/exec"
14 "path/filepath" 16 "path/filepath"
17 "strings"
15 "time" 18 "time"
16 19
17 "gitbay.org/gitbay/internal/config" 20 "gitbay.org/gitbay/internal/config"
18 "gitbay.org/gitbay/internal/control" 21 "gitbay.org/gitbay/internal/control"
19 "gitbay.org/gitbay/internal/store" 22 "gitbay.org/gitbay/internal/store"
20 "gitbay.org/gitbay/internal/toolpath" 23 "gitbay.org/gitbay/internal/toolpath"
24 "gitbay.org/gitbay/internal/webhook"
21) 25)
22 26
23const askpassScript = `#!/bin/sh 27const askpassScript = `#!/bin/sh
@@ -31,6 +35,8 @@ type Worker struct {
31 St *store.Store 35 St *store.Store
32 Cfg config.Config 36 Cfg config.Config
33 Tick time.Duration 37 Tick time.Duration
38 // Lookup resolves a mirror's host immediately before each sync.
39 Lookup func(ctx context.Context, host string) ([]net.IP, error)
34} 40}
35 41
36func New(st *store.Store, cfg config.Config) *Worker { 42func New(st *store.Store, cfg config.Config) *Worker {
@@ -40,7 +46,10 @@ func New(st *store.Store, cfg config.Config) *Worker {
40 tick = d 46 tick = d
41 } 47 }
42 } 48 }
43 return &Worker{St: st, Cfg: cfg, Tick: tick} 49 return &Worker{St: st, Cfg: cfg, Tick: tick,
50 Lookup: func(ctx context.Context, host string) ([]net.IP, error) {
51 return net.DefaultResolver.LookupIP(ctx, "ip", host)
52 }}
44} 53}
45 54
46func (w *Worker) Run(ctx context.Context) { 55func (w *Worker) Run(ctx context.Context) {
@@ -79,6 +88,30 @@ func (w *Worker) sync(m store.Mirror) error {
79 return err 88 return err
80 } 89 }
81 dir := control.RepoDir(w.Cfg.Server.Root, repo.OwnerName, repo.Name) 90 dir := control.RepoDir(w.Cfg.Server.Root, repo.OwnerName, repo.Name)
91 u, err := url.Parse(m.URL)
92 if err != nil {
93 return err
94 }
95 if u.Scheme != "https" && u.Scheme != "http" {
96 return fmt.Errorf("mirror URL scheme %q is not http or https", u.Scheme)
97 }
98
99 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
100 defer cancel()
101 // The URL was checked when saved, but DNS can answer differently
102 // now. Check what it resolves to at sync time, then let git connect
103 // to exactly those addresses.
104 ips, err := w.Lookup(ctx, u.Hostname())
105 if err != nil {
106 return fmt.Errorf("resolving %s: %w", u.Hostname(), err)
107 }
108 if len(ips) == 0 {
109 // An empty resolve list would leave curl to resolve the host itself.
110 return fmt.Errorf("%s resolves to no address", u.Hostname())
111 }
112 if err := webhook.CheckAddrs(u.Hostname(), ips, w.Cfg.Webhooks.AllowLocal); err != nil {
113 return err
114 }
82 115
83 env := []string{"GIT_TERMINAL_PROMPT=0", "HOME=" + w.Cfg.Server.Root} 116 env := []string{"GIT_TERMINAL_PROMPT=0", "HOME=" + w.Cfg.Server.Root}
84 if m.Token != "" { 117 if m.Token != "" {
@@ -96,16 +129,14 @@ func (w *Worker) sync(m store.Mirror) error {
96 "GITBAY_MIRROR_TOKEN="+m.Token) 129 "GITBAY_MIRROR_TOKEN="+m.Token)
97 } 130 }
98 131
99 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) 132 args := append(pinArgs(u, ips), "-C", dir)
100 defer cancel()
101 var args []string
102 if m.Direction == "push" { 133 if m.Direction == "push" {
103 // Branches and tags only: internal refs (merge-requests) stay home. 134 // Branches and tags only: internal refs (merge-requests) stay home.
104 args = []string{"-C", dir, "push", "--prune", m.URL, 135 args = append(args, "push", "--prune", m.URL,
105 "+refs/heads/*:refs/heads/*", "+refs/tags/*:refs/tags/*"} 136 "+refs/heads/*:refs/heads/*", "+refs/tags/*:refs/tags/*")
106 } else { 137 } else {
107 args = []string{"-C", dir, "fetch", "--prune", m.URL, 138 args = append(args, "fetch", "--prune", m.URL,
108 "+refs/heads/*:refs/heads/*", "+refs/tags/*:refs/tags/*"} 139 "+refs/heads/*:refs/heads/*", "+refs/tags/*:refs/tags/*")
109 } 140 }
110 cmd := exec.CommandContext(ctx, toolpath.Look("git"), args...) 141 cmd := exec.CommandContext(ctx, toolpath.Look("git"), args...)
111 cmd.Env = env 142 cmd.Env = env
@@ -114,3 +145,30 @@ func (w *Worker) sync(m store.Mirror) error {
114 } 145 }
115 return nil 146 return nil
116} 147}
148
149// pinArgs keeps git on the addresses just checked: curl's resolve list
150// pins the host, and with redirects off a server cannot send git on to
151// a host nobody checked. An address literal needs no pin.
152func pinArgs(u *url.URL, ips []net.IP) []string {
153 args := []string{"-c", "http.followRedirects=false"}
154 host := u.Hostname()
155 if net.ParseIP(host) != nil {
156 return args
157 }
158 port := u.Port()
159 if port == "" {
160 port = "443"
161 if u.Scheme == "http" {
162 port = "80"
163 }
164 }
165 addrs := make([]string, len(ips))
166 for i, ip := range ips {
167 if ip.To4() == nil {
168 addrs[i] = "[" + ip.String() + "]"
169 } else {
170 addrs[i] = ip.String()
171 }
172 }
173 return append(args, "-c", "http.curloptResolve="+host+":"+port+":"+strings.Join(addrs, ","))
174}
internal/mirror/mirror_test.go added +197
@@ -0,0 +1,197 @@
1package mirror
2
3import (
4 "context"
5 "net"
6 "net/http/cgi"
7 "net/http/httptest"
8 "net/url"
9 "os"
10 "os/exec"
11 "path/filepath"
12 "slices"
13 "strings"
14 "testing"
15
16 "gitbay.org/gitbay/internal/config"
17 "gitbay.org/gitbay/internal/control"
18 "gitbay.org/gitbay/internal/store"
19)
20
21func git(t *testing.T, dir string, args ...string) string {
22 t.Helper()
23 cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
24 cmd.Env = append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1", "HOME="+t.TempDir(),
25 "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.test",
26 "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.test")
27 out, err := cmd.CombinedOutput()
28 if err != nil {
29 t.Fatalf("git %v: %v\n%s", args, err, out)
30 }
31 return strings.TrimSpace(string(out))
32}
33
34// upstream serves a bare repository with one commit on main over smart
35// HTTP and returns its URL and that commit.
36func upstream(t *testing.T) (string, string) {
37 t.Helper()
38 parent := t.TempDir()
39 bare := filepath.Join(parent, "remote.git")
40 work := filepath.Join(parent, "work")
41 git(t, parent, "init", "-q", "--bare", "--initial-branch=main", bare)
42 git(t, parent, "init", "-q", "--initial-branch=main", work)
43 git(t, work, "commit", "-q", "--allow-empty", "-m", "one")
44 git(t, work, "push", "-q", bare, "main")
45 sha := git(t, work, "rev-parse", "HEAD")
46 execPath := git(t, parent, "--exec-path")
47 srv := httptest.NewServer(&cgi.Handler{
48 Path: filepath.Join(execPath, "git-http-backend"),
49 Env: []string{"GIT_PROJECT_ROOT=" + parent, "GIT_HTTP_EXPORT_ALL=1"},
50 })
51 t.Cleanup(srv.Close)
52 return srv.URL + "/remote.git", sha
53}
54
55// local returns a store with alice/app, its bare repository under root,
56// and the pull mirror row for url.
57func local(t *testing.T, root, mirrorURL string) (*store.Store, store.Mirror, string) {
58 t.Helper()
59 st, err := store.Open(filepath.Join(t.TempDir(), "gitbay.db"))
60 if err != nil {
61 t.Fatal(err)
62 }
63 t.Cleanup(func() { st.Close() })
64 if err := st.MigrateUp(); err != nil {
65 t.Fatal(err)
66 }
67 uid, err := st.CreateUser("alice", false)
68 if err != nil {
69 t.Fatal(err)
70 }
71 repoID, err := st.CreateRepo("user", uid, "app", "public")
72 if err != nil {
73 t.Fatal(err)
74 }
75 dir := control.RepoDir(root, "alice", "app")
76 os.MkdirAll(filepath.Dir(dir), 0o755)
77 git(t, root, "init", "-q", "--bare", dir)
78 if _, err := st.AddMirror(repoID, "pull", mirrorURL, "", ""); err != nil {
79 t.Fatal(err)
80 }
81 due, err := st.DueMirrors(900)
82 if err != nil || len(due) != 1 {
83 t.Fatalf("due mirrors: %v %v", due, err)
84 }
85 return st, due[0], dir
86}
87
88// mirror.test does not resolve; the fetch works only because git was
89// pinned to the address the worker looked up and checked.
90func TestSyncConnectsToTheCheckedAddress(t *testing.T) {
91 remote, sha := upstream(t)
92 u, _ := url.Parse(remote)
93 root := t.TempDir()
94 st, m, dir := local(t, root, "http://mirror.test:"+u.Port()+"/remote.git")
95 var cfg config.Config
96 cfg.Server.Root = root
97 cfg.Webhooks.AllowLocal = true
98 var asked []string
99 w := &Worker{St: st, Cfg: cfg, Lookup: func(ctx context.Context, host string) ([]net.IP, error) {
100 asked = append(asked, host)
101 return []net.IP{net.ParseIP("127.0.0.1")}, nil
102 }}
103 if err := w.sync(m); err != nil {
104 t.Fatal(err)
105 }
106 if got := git(t, dir, "rev-parse", "refs/heads/main"); got != sha {
107 t.Fatalf("main = %s, want %s", got, sha)
108 }
109 if !slices.Equal(asked, []string{"mirror.test"}) {
110 t.Fatalf("looked up %v", asked)
111 }
112}
113
114// The URL passed the check when it was saved; the answer at sync time
115// is what counts.
116func TestSyncRefusesAPrivateAddressAtSyncTime(t *testing.T) {
117 root := t.TempDir()
118 st, m, _ := local(t, root, "https://mirror.test/x.git")
119 var cfg config.Config
120 cfg.Server.Root = root
121 w := &Worker{St: st, Cfg: cfg, Lookup: func(context.Context, string) ([]net.IP, error) {
122 return []net.IP{net.ParseIP("10.0.0.7")}, nil
123 }}
124 err := w.sync(m)
125 if err == nil || !strings.Contains(err.Error(), "10.0.0.7") {
126 t.Fatalf("sync = %v, want a refusal naming 10.0.0.7", err)
127 }
128}
129
130// A refusal is a sync failure like any other: the sweep records it on
131// the mirror, where repo mirror list shows it.
132func TestSweepRecordsTheRefusal(t *testing.T) {
133 root := t.TempDir()
134 st, m, _ := local(t, root, "https://mirror.test/x.git")
135 var cfg config.Config
136 cfg.Server.Root = root
137 cfg.Mirrors.PullIntervalMinutes = 15
138 w := &Worker{St: st, Cfg: cfg, Lookup: func(context.Context, string) ([]net.IP, error) {
139 return []net.IP{net.ParseIP("100.64.0.9")}, nil
140 }}
141 w.sweep()
142 ms, err := st.ListMirrors(m.RepoID)
143 if err != nil || len(ms) != 1 {
144 t.Fatalf("mirrors: %v %v", ms, err)
145 }
146 if !strings.Contains(ms[0].LastError, "100.64.0.9") {
147 t.Fatalf("last error = %q", ms[0].LastError)
148 }
149}
150
151func TestSyncRefusesAnEmptyAnswer(t *testing.T) {
152 root := t.TempDir()
153 st, m, _ := local(t, root, "https://mirror.test/x.git")
154 var cfg config.Config
155 cfg.Server.Root = root
156 cfg.Webhooks.AllowLocal = true
157 w := &Worker{St: st, Cfg: cfg, Lookup: func(context.Context, string) ([]net.IP, error) {
158 return nil, nil
159 }}
160 if err := w.sync(m); err == nil || !strings.Contains(err.Error(), "no address") {
161 t.Fatalf("sync = %v, want a refusal", err)
162 }
163}
164
165func TestSyncRefusesANonHTTPScheme(t *testing.T) {
166 root := t.TempDir()
167 st, m, _ := local(t, root, "ssh://mirror.test/x.git")
168 var cfg config.Config
169 cfg.Server.Root = root
170 cfg.Webhooks.AllowLocal = true
171 w := &Worker{St: st, Cfg: cfg, Lookup: func(context.Context, string) ([]net.IP, error) {
172 t.Fatal("looked up a host for an ssh URL")
173 return nil, nil
174 }}
175 if err := w.sync(m); err == nil || !strings.Contains(err.Error(), "not http or https") {
176 t.Fatalf("sync = %v, want a refusal", err)
177 }
178}
179
180func TestPinArgs(t *testing.T) {
181 u, _ := url.Parse("https://git.example/x.git")
182 got := pinArgs(u, []net.IP{net.ParseIP("203.0.113.5"), net.ParseIP("2001:db8::1")})
183 want := []string{"-c", "http.followRedirects=false",
184 "-c", "http.curloptResolve=git.example:443:203.0.113.5,[2001:db8::1]"}
185 if !slices.Equal(got, want) {
186 t.Fatalf("https: %q", got)
187 }
188 u, _ = url.Parse("http://git.example:8080/x.git")
189 if got := pinArgs(u, []net.IP{net.ParseIP("203.0.113.5")}); got[3] != "http.curloptResolve=git.example:8080:203.0.113.5" {
190 t.Fatalf("http with port: %q", got)
191 }
192 // An address literal is its own resolution; there is nothing to pin.
193 u, _ = url.Parse("https://203.0.113.5/x.git")
194 if got := pinArgs(u, []net.IP{net.ParseIP("203.0.113.5")}); !slices.Equal(got, []string{"-c", "http.followRedirects=false"}) {
195 t.Fatalf("literal: %q", got)
196 }
197}