v0.35.1 2aca1df

mirror.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package domain
4
5import (
6	"fmt"
7	"net"
8	"net/url"
9	"strconv"
10	"strings"
11	"time"
12	"unicode"
13)
14
15const (
16	MirrorMinInterval     = 5 * time.Minute  // densest cron allowed
17	MirrorManualInterval  = 60 * time.Second // min gap between manual syncs (not --force)
18	MirrorRepoPlaceholder = "{repo}"
19)
20
21// MirrorGlobalConfig is the server-wide mirror policy.
22type MirrorGlobalConfig struct {
23	Enabled     bool
24	URLTemplate string // ssh URL containing MirrorRepoPlaceholder; "" allowed while disabled
25	Cron        string // 5-field cron, UTC; "" allowed while disabled
26	AllBranches bool
27	UpdatedAt   time.Time
28}
29
30// MirrorRepoConfig is the per-repository entry: an override (URL != "") or an exclusion.
31type MirrorRepoConfig struct {
32	Repo        RepoName
33	URL         string // "" means no override
34	Cron        string // "" means inherit global cron
35	AllBranches *bool  // nil means inherit
36	Excluded    bool   // opt out of the global template; ignored when URL != ""
37	UpdatedAt   time.Time
38}
39type MirrorSource string
40
41const (
42	MirrorSourceNone     MirrorSource = "disabled" // nothing applies
43	MirrorSourceGlobal   MirrorSource = "global"
44	MirrorSourceOverride MirrorSource = "override"
45	MirrorSourceExcluded MirrorSource = "excluded"
46)
47
48// MirrorTarget is what one repository is mirrored to after resolution.
49type MirrorTarget struct {
50	Repo        RepoName
51	Enabled     bool
52	URL         string
53	Cron        Cron // zero when !Enabled
54	CronSpec    string
55	AllBranches bool
56	Source      MirrorSource
57}
58
59func mirrorURLParts(s string) (host, path string, err error) {
60	bad := func() (string, string, error) {
61		return "", "", fmt.Errorf("%w: mirror url must be an SSH host and repository path without credentials", ErrInvalid)
62	}
63	if strings.ContainsFunc(s, func(r rune) bool { return unicode.IsControl(r) || unicode.IsSpace(r) }) {
64		return bad()
65	}
66	if strings.HasPrefix(s, "ssh://") {
67		u, e := url.Parse(s)
68		if e != nil || u.Hostname() == "" || u.RawQuery != "" || u.Fragment != "" {
69			return bad()
70		}
71		if u.User != nil {
72			if _, ok := u.User.Password(); ok {
73				return bad()
74			}
75			if u.User.Username() == "" || strings.HasPrefix(u.User.Username(), "-") {
76				return bad()
77			}
78		}
79		if p := u.Port(); p != "" {
80			n, e := strconv.Atoi(p)
81			if e != nil || n < 1 || n > 65535 {
82				return bad()
83			}
84		}
85		host = u.Host
86		path = strings.TrimPrefix(u.Path, "/")
87	} else {
88		if strings.Contains(s, "://") {
89			return bad()
90		}
91		var ok bool
92		host, path, ok = strings.Cut(s, ":")
93		if !ok {
94			return bad()
95		}
96		if strings.Contains(host, "@") {
97			user, h, _ := strings.Cut(host, "@")
98			if user == "" || strings.HasPrefix(user, "-") || strings.ContainsAny(user, ":/@") {
99				return bad()
100			}
101			host = h
102		}
103		if strings.ContainsAny(host, "/@:") {
104			return bad()
105		}
106	}
107	if host == "" || strings.HasPrefix(host, "-") || path == "" || strings.HasPrefix(path, "-") || strings.Contains(path, "@") {
108		return bad()
109	}
110	// Reject malformed bracketed addresses as well as empty hosts.
111	if strings.HasPrefix(host, "[") {
112		h := strings.TrimSuffix(strings.TrimPrefix(host, "["), "]")
113		if x, _, e := net.SplitHostPort(host); e == nil {
114			h = strings.Trim(x, "[]")
115		}
116		if net.ParseIP(h) == nil {
117			return bad()
118		}
119	}
120	return host, path, nil
121}
122
123// ValidateMirrorURL accepts "git@host:path" (scp-like, user optional, no ':' port) and
124// "ssh://[user@]host[:port]/path". Anything else, an empty host, control chars, or a path
125// starting with "-" is "%w: mirror url ..." with ErrInvalid. URL is never logged in full
126// elsewhere: use RedactMirrorURL.
127func ValidateMirrorURL(s string) error { _, _, err := mirrorURLParts(s); return err }
128func RedactMirrorURL(s string) string {
129	h, p, e := mirrorURLParts(s)
130	if e != nil {
131		return "[invalid mirror URL]"
132	}
133	return h + ":" + p
134}
135
136// ExpandMirrorURL replaces MirrorRepoPlaceholder with repo (as a path segment, not escaped
137// beyond RepoName's own charset). Returns ErrInvalid when the template has no placeholder.
138func ExpandMirrorURL(template string, repo RepoName) (string, error) {
139	if !strings.Contains(template, MirrorRepoPlaceholder) {
140		return "", fmt.Errorf("%w: mirror url template needs {repo}", ErrInvalid)
141	}
142	return strings.ReplaceAll(template, MirrorRepoPlaceholder, repo.String()), nil
143}
144func mirrorCron(spec string) (Cron, error) {
145	c, e := ParseCron(spec)
146	if e != nil {
147		return c, e
148	}
149	if gap := CronMinGap(c); gap > 0 && gap < MirrorMinInterval {
150		return Cron{}, fmt.Errorf("%w: cron interval must be at least five minutes", ErrInvalid)
151	}
152	return c, nil
153}
154
155// ValidateMirrorGlobalConfig / ValidateMirrorRepoConfig return InvalidFieldsError with fields
156// "url_template", "cron", "url": cron parsed with ParseCron and checked by CronMinGap >= MirrorMinInterval;
157// url_template must contain the placeholder; when Enabled both are required.
158func ValidateMirrorGlobalConfig(g MirrorGlobalConfig) error {
159	return ValidateMirrorGlobalConfigWith(g, ValidateMirrorURL)
160}
161
162// ValidateMirrorGlobalConfigWith is ValidateMirrorGlobalConfig with another URL check, for
163// deployments and tests that accept more than SSH URLs.
164func ValidateMirrorGlobalConfigWith(g MirrorGlobalConfig, validateURL func(string) error) error {
165	var fields []FieldViolation
166	if g.Enabled || g.URLTemplate != "" {
167		repo, _ := ParseRepoName("repo")
168		u, e := ExpandMirrorURL(g.URLTemplate, repo)
169		if e == nil {
170			e = validateURL(u)
171		}
172		if e != nil {
173			fields = append(fields, FieldViolation{"url_template", "must be an SSH URL template containing {repo}"})
174		}
175	}
176	if g.Enabled || g.Cron != "" {
177		if _, e := mirrorCron(g.Cron); e != nil {
178			fields = append(fields, FieldViolation{"cron", e.Error()})
179		}
180	}
181	if len(fields) > 0 {
182		return &InvalidFieldsError{Fields: fields}
183	}
184	return nil
185}
186func ValidateMirrorRepoConfig(r MirrorRepoConfig) error {
187	return ValidateMirrorRepoConfigWith(r, ValidateMirrorURL)
188}
189
190// ValidateMirrorRepoConfigWith is ValidateMirrorRepoConfig with another URL check.
191func ValidateMirrorRepoConfigWith(r MirrorRepoConfig, validateURL func(string) error) error {
192	var fields []FieldViolation
193	if r.URL != "" {
194		if e := validateURL(r.URL); e != nil {
195			fields = append(fields, FieldViolation{"url", e.Error()})
196		}
197	}
198	if r.Cron != "" {
199		if _, e := mirrorCron(r.Cron); e != nil {
200			fields = append(fields, FieldViolation{"cron", e.Error()})
201		}
202	}
203	if len(fields) > 0 {
204		return &InvalidFieldsError{Fields: fields}
205	}
206	return nil
207}
208
209// ResolveMirror: override wins even when global is disabled; else global, unless excluded.
210func ResolveMirror(g MirrorGlobalConfig, r *MirrorRepoConfig, repo RepoName) (MirrorTarget, error) {
211	return ResolveMirrorWith(g, r, repo, ValidateMirrorURL)
212}
213
214// ResolveMirrorWith is ResolveMirror with another URL check.
215func ResolveMirrorWith(g MirrorGlobalConfig, r *MirrorRepoConfig, repo RepoName, validateURL func(string) error) (MirrorTarget, error) {
216	t := MirrorTarget{Repo: repo, Source: MirrorSourceNone}
217	if r != nil && r.URL != "" {
218		t.URL = r.URL
219		t.Source = MirrorSourceOverride
220	} else if r != nil && r.Excluded {
221		t.Source = MirrorSourceExcluded
222		return t, nil
223	} else if g.Enabled {
224		var e error
225		t.URL, e = ExpandMirrorURL(g.URLTemplate, repo)
226		if e != nil {
227			return t, e
228		}
229		t.Source = MirrorSourceGlobal
230	} else {
231		return t, nil
232	}
233	t.CronSpec = g.Cron
234	t.AllBranches = g.AllBranches
235	if r != nil {
236		if r.Cron != "" {
237			t.CronSpec = r.Cron
238		}
239		if r.AllBranches != nil {
240			t.AllBranches = *r.AllBranches
241		}
242	}
243	if e := validateURL(t.URL); e != nil {
244		return t, e
245	}
246	// Mutation boundaries validate the minimum gap; resolution only parses.
247	c, e := ParseCron(t.CronSpec)
248	if e != nil {
249		return t, e
250	}
251	t.Cron = c
252	t.Enabled = true
253	return t, nil
254}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 4

Tags 45

↑↓ moveenter openesc close