detached c59b5fa

fix(domain): check the cron gap across the year, parse only on resolution and classify more SSH auth denials

commit
c59b5fa4a6da7b23f4d28e03f16c7dee31fbd7ab
author
brice · · 2026-10-20 06:15 UTC
committer
brice · · 2026-10-20 10:02 UTC
parent
4d330ea
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +53 -2
M internal/domain/mirror.go +2 -1
@@ -227,7 +227,8 @@ func ResolveMirror(g MirrorGlobalConfig, r *MirrorRepoConfig, repo RepoName) (Mi
227227 if e := ValidateMirrorURL(t.URL); e != nil {
228228 return t, e
229229 }
230- c, e := mirrorCron(t.CronSpec)
230+ // Mutation boundaries validate the minimum gap; resolution only parses.
231+ c, e := ParseCron(t.CronSpec)
231232 if e != nil {
232233 return t, e
233234 }
@@ -227,7 +227,8 @@ func ResolveMirror(g MirrorGlobalConfig, r *MirrorRepoConfig, repo RepoName) (Mi
227 if e := ValidateMirrorURL(t.URL); e != nil {227 if e := ValidateMirrorURL(t.URL); e != nil {
228 return t, e228 return t, e
229 }229 }
230- c, e := mirrorCron(t.CronSpec)230+ // Mutation boundaries validate the minimum gap; resolution only parses.
231+ c, e := ParseCron(t.CronSpec)
231 if e != nil {232 if e != nil {
232 return t, e233 return t, e
233 }234 }
M internal/domain/mirror_cron.go +2 -1
@@ -5,7 +5,8 @@ import "time"
55
66 func CronMinGap(c Cron) time.Duration {
77 start := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
8- end := start.AddDate(1, 0, 0)
8+ // Include the next January so adjacent December/January runs are compared.
9+ end := start.AddDate(1, 1, 0)
910 var prev time.Time
1011 var gap time.Duration
1112 for t := start; t.Before(end); t = t.Add(time.Minute) {
@@ -5,7 +5,8 @@ import "time"
5 5
6 func CronMinGap(c Cron) time.Duration {6 func CronMinGap(c Cron) time.Duration {
7 start := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)7 start := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
8- end := start.AddDate(1, 0, 0)8+ // Include the next January so adjacent December/January runs are compared.
9+ end := start.AddDate(1, 1, 0)
9 var prev time.Time10 var prev time.Time
10 var gap time.Duration11 var gap time.Duration
11 for t := start; t.Before(end); t = t.Add(time.Minute) {12 for t := start; t.Before(end); t = t.Add(time.Minute) {
M internal/domain/mirror_state.go +11 -0
@@ -84,6 +84,17 @@ func ClassifyMirrorOutput(stderr string, exitErr error, timedOut bool) MirrorFai
8484 return MirrorClassTransient
8585 }
8686 s := strings.ToLower(stderr)
87+ for _, line := range strings.Split(s, "\n") {
88+ if i := strings.Index(line, "permission denied ("); i >= 0 {
89+ methods, _, closed := strings.Cut(line[i+len("permission denied ("):], ")")
90+ if closed && strings.Contains(methods, "publickey") {
91+ return MirrorClassAuthDenied
92+ }
93+ }
94+ if strings.Contains(line, "permission to ") && strings.Contains(line, " denied") {
95+ return MirrorClassAuthDenied
96+ }
97+ }
8798 for _, entry := range []struct {
8899 class MirrorFailureClass
89100 markers []string
@@ -84,6 +84,17 @@ func ClassifyMirrorOutput(stderr string, exitErr error, timedOut bool) MirrorFai
84 return MirrorClassTransient84 return MirrorClassTransient
85 }85 }
86 s := strings.ToLower(stderr)86 s := strings.ToLower(stderr)
87+ for _, line := range strings.Split(s, "\n") {
88+ if i := strings.Index(line, "permission denied ("); i >= 0 {
89+ methods, _, closed := strings.Cut(line[i+len("permission denied ("):], ")")
90+ if closed && strings.Contains(methods, "publickey") {
91+ return MirrorClassAuthDenied
92+ }
93+ }
94+ if strings.Contains(line, "permission to ") && strings.Contains(line, " denied") {
95+ return MirrorClassAuthDenied
96+ }
97+ }
87 for _, entry := range []struct {98 for _, entry := range []struct {
88 class MirrorFailureClass99 class MirrorFailureClass
89 markers []string100 markers []string
M internal/domain/mirror_test.go +38 -0
@@ -275,3 +275,41 @@ func TestMirrorNextCronFallBack(t *testing.T) {
275275 t.Fatal(got, ok)
276276 }
277277 }
278+
279+func TestMirrorCronYearBoundary(t *testing.T) {
280+ spec := "0,59 0,23 1,31 1,12 *"
281+ c, err := ParseCron(spec)
282+ if err != nil {
283+ t.Fatal(err)
284+ }
285+ if got := CronMinGap(c); got != time.Minute {
286+ t.Fatal(got)
287+ }
288+ if err := ValidateMirrorRepoConfig(MirrorRepoConfig{Cron: spec}); err == nil {
289+ t.Fatal("accepted one-minute year-boundary gap")
290+ }
291+}
292+
293+func TestMirrorResolutionOnlyParsesCron(t *testing.T) {
294+ g := MirrorGlobalConfig{Enabled: true, URLTemplate: "host:{repo}", Cron: "* * * * *"}
295+ repo, _ := ParseRepoName("demo")
296+ if _, err := ResolveMirror(g, nil, repo); err != nil {
297+ t.Fatal(err)
298+ }
299+ if ValidateMirrorGlobalConfig(g) == nil {
300+ t.Fatal("mutation must still reject short gap")
301+ }
302+}
303+
304+func TestMirrorClassifyAuthVariants(t *testing.T) {
305+ for _, s := range []string{
306+ "Permission denied (publickey,password).",
307+ "Permission denied (password,publickey,keyboard-interactive).",
308+ "ERROR: Permission to x/y.git denied to deploy key",
309+ "remote: Permission to x/y.git denied to someone.",
310+ } {
311+ if got := ClassifyMirrorOutput(s, errors.New("exit"), false); got != MirrorClassAuthDenied {
312+ t.Fatalf("%q: %s", s, got)
313+ }
314+ }
315+}
@@ -275,3 +275,41 @@ func TestMirrorNextCronFallBack(t *testing.T) {
275 t.Fatal(got, ok)275 t.Fatal(got, ok)
276 }276 }
277 }277 }
278+
279+func TestMirrorCronYearBoundary(t *testing.T) {
280+ spec := "0,59 0,23 1,31 1,12 *"
281+ c, err := ParseCron(spec)
282+ if err != nil {
283+ t.Fatal(err)
284+ }
285+ if got := CronMinGap(c); got != time.Minute {
286+ t.Fatal(got)
287+ }
288+ if err := ValidateMirrorRepoConfig(MirrorRepoConfig{Cron: spec}); err == nil {
289+ t.Fatal("accepted one-minute year-boundary gap")
290+ }
291+}
292+
293+func TestMirrorResolutionOnlyParsesCron(t *testing.T) {
294+ g := MirrorGlobalConfig{Enabled: true, URLTemplate: "host:{repo}", Cron: "* * * * *"}
295+ repo, _ := ParseRepoName("demo")
296+ if _, err := ResolveMirror(g, nil, repo); err != nil {
297+ t.Fatal(err)
298+ }
299+ if ValidateMirrorGlobalConfig(g) == nil {
300+ t.Fatal("mutation must still reject short gap")
301+ }
302+}
303+
304+func TestMirrorClassifyAuthVariants(t *testing.T) {
305+ for _, s := range []string{
306+ "Permission denied (publickey,password).",
307+ "Permission denied (password,publickey,keyboard-interactive).",
308+ "ERROR: Permission to x/y.git denied to deploy key",
309+ "remote: Permission to x/y.git denied to someone.",
310+ } {
311+ if got := ClassifyMirrorOutput(s, errors.New("exit"), false); got != MirrorClassAuthDenied {
312+ t.Fatalf("%q: %s", s, got)
313+ }
314+ }
315+}

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 2

Tags 42

↑↓ moveenter openesc close