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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | |||
| 227 | 227 | if e := ValidateMirrorURL(t.URL); e != nil { | |
| 228 | 228 | 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 | 232 | if e != nil { | |
| 232 | 233 | return t, e | |
| 233 | 234 | } | |
| @@ -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, e | 228 | 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, e | 233 | return t, e | ||
| 233 | } | 234 | } | ||
M internal/domain/mirror_cron.go +2 -1
| @@ -5,7 +5,8 @@ import "time" | |||
| 5 | 5 | ||
| 6 | 6 | func CronMinGap(c Cron) time.Duration { | |
| 7 | 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 | 10 | var prev time.Time | |
| 10 | 11 | var gap time.Duration | |
| 11 | 12 | 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.Time | 10 | var prev time.Time | ||
| 10 | var gap time.Duration | 11 | 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 | |||
| 84 | 84 | return MirrorClassTransient | |
| 85 | 85 | } | |
| 86 | 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 | 98 | for _, entry := range []struct { | |
| 88 | 99 | class MirrorFailureClass | |
| 89 | 100 | markers []string | |
| @@ -84,6 +84,17 @@ func ClassifyMirrorOutput(stderr string, exitErr error, timedOut bool) MirrorFai | |||||
| 84 | return MirrorClassTransient | 84 | 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 MirrorFailureClass | 99 | class MirrorFailureClass | ||
| 89 | markers []string | 100 | markers []string | ||
M internal/domain/mirror_test.go +38 -0
| @@ -275,3 +275,41 @@ func TestMirrorNextCronFallBack(t *testing.T) { | |||
| 275 | 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 | + | } | |
| @@ -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 | + | } | |||