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}