@@ -9,13 +9,13 @@ import (
99 "log/slog"
1010 "net/url"
1111 "os"
12+ "regexp"
1213 "strings"
1314 "time"
1415
1516 "github.com/dvflw/mantle/internal/budget"
1617 "github.com/dvflw/mantle/internal/dbdefaults"
1718 "github.com/dvflw/mantle/internal/netutil"
18- "github.com/dvflw/mantle/internal/repo"
1919 "github.com/spf13/cobra"
2020 "github.com/spf13/viper"
2121)
@@ -363,19 +363,17 @@ func Load(cmd *cobra.Command) (*Config, error) {
363363 // it only needs to catch the "user@host" form that people may put in a
364364 // config file. Full store-level validation runs again at registration time.
365365 for i , r := range cfg .GitSync .Repos {
366- if err := repo . ValidateName (r .Name ); err != nil {
366+ if err := validateGitSyncName (r .Name ); err != nil {
367367 return nil , fmt .Errorf ("git_sync.repos[%d]: %w" , i , err )
368368 }
369369 if r .URL == "" {
370370 return nil , fmt .Errorf ("git_sync.repos[%d] (%q): url is required" , i , r .Name )
371371 }
372- if parsed , err := url .Parse (r .URL ); err != nil {
373- return nil , fmt .Errorf ("git_sync.repos[%d] (%q): invalid url: %w" , i , r .Name , err )
374- } else if parsed .User != nil {
375- return nil , fmt .Errorf ("git_sync.repos[%d] (%q): url must not embed credentials" , i , r .Name )
372+ if err := validateGitSyncURL (r .URL ); err != nil {
373+ return nil , fmt .Errorf ("git_sync.repos[%d] (%q): %w" , i , r .Name , err )
376374 }
377375 if r .PollInterval != "" {
378- if err := repo . ValidatePollInterval (r .PollInterval ); err != nil {
376+ if err := validateGitSyncPollInterval (r .PollInterval ); err != nil {
379377 return nil , fmt .Errorf ("git_sync.repos[%d] (%q): %w" , i , r .Name , err )
380378 }
381379 }
@@ -415,3 +413,52 @@ func Load(cmd *cobra.Command) (*Config, error) {
415413
416414 return & cfg , nil
417415}
416+
417+ // gitSyncNamePattern enforces DNS-label-like names: lowercase alphanumerics,
418+ // underscores, and hyphens, starting and ending with an alphanumeric.
419+ // This is a local copy of the pattern in packages/engine/internal/repo/types.go
420+ // kept here to avoid a config → repo → auth test-time import cycle.
421+ var gitSyncNamePattern = regexp .MustCompile (`^[a-z0-9]([a-z0-9_-]*[a-z0-9])?$` )
422+
423+ // validateGitSyncName is a local copy of repo.ValidateName to avoid a
424+ // config → repo → auth test-time import cycle. Keep in sync with the
425+ // canonical version in packages/engine/internal/repo/types.go.
426+ func validateGitSyncName (name string ) error {
427+ if name == "" {
428+ return fmt .Errorf ("repo name is required" )
429+ }
430+ if len (name ) > 63 {
431+ return fmt .Errorf ("invalid repo name %q: length %d exceeds 63-char cap" , name , len (name ))
432+ }
433+ if ! gitSyncNamePattern .MatchString (name ) {
434+ return fmt .Errorf ("invalid repo name %q: must match %s" , name , gitSyncNamePattern .String ())
435+ }
436+ return nil
437+ }
438+
439+ // validateGitSyncPollInterval is a local copy of repo.ValidatePollInterval.
440+ // See validateGitSyncName for the rationale.
441+ func validateGitSyncPollInterval (interval string ) error {
442+ d , err := time .ParseDuration (interval )
443+ if err != nil {
444+ return fmt .Errorf ("invalid poll_interval %q: %w" , interval , err )
445+ }
446+ if d < 10 * time .Second {
447+ return fmt .Errorf ("poll_interval %q below 10s minimum" , interval )
448+ }
449+ return nil
450+ }
451+
452+ // validateGitSyncURL rejects any URL that embeds credentials in the
453+ // userinfo component (https://user@host or https://user:pass@host). All
454+ // auth material must flow through the Credential reference.
455+ func validateGitSyncURL (raw string ) error {
456+ u , err := url .Parse (raw )
457+ if err != nil {
458+ return fmt .Errorf ("invalid url %q: %w" , raw , err )
459+ }
460+ if u .User != nil {
461+ return fmt .Errorf ("repo url must not embed credentials — use the Credential field instead" )
462+ }
463+ return nil
464+ }
0 commit comments