diff --git a/config.go b/config.go index b89c7ae..ea0fde1 100644 --- a/config.go +++ b/config.go @@ -3,8 +3,11 @@ package main import ( "context" "errors" + "fmt" "os" "path" + "reflect" + "slices" "time" "github.com/prometheus/client_golang/prometheus" @@ -32,6 +35,11 @@ var ( Name: "git_mirror_config_last_reload_success_timestamp_seconds", Help: "Timestamp of the last successful configuration reload.", }) + allowedRepoPoolConfig = getAllowedKeys(mirror.RepoPoolConfig{}) + allowedDefaults = getAllowedKeys(mirror.DefaultConfig{}) + allowedAuthKeys = getAllowedKeys(mirror.Auth{}) + allowedRepoKeys = getAllowedKeys(mirror.RepositoryConfig{}) + allowedWorktreeKeys = getAllowedKeys(mirror.WorktreeConfig{}) ) // WatchConfig polls the config file every interval and reloads if modified @@ -174,14 +182,132 @@ func parseConfigFile(path string) (*mirror.RepoPoolConfig, error) { if err != nil { return nil, err } + + err = validateConfigYaml([]byte(yamlFile)) + if err != nil { + return nil, err + } + conf := &mirror.RepoPoolConfig{} err = yaml.Unmarshal(yamlFile, conf) if err != nil { return nil, err } + return conf, nil } +func validateConfigYaml(yamlData []byte) error { + var raw map[string]interface{} + if err := yaml.Unmarshal(yamlData, &raw); err != nil { + return err + } + + // check all root config sections for unexpected keys + if key := findUnexpectedKey(raw, allowedRepoPoolConfig); key != "" { + return fmt.Errorf("unexpected key: .%v", key) + } + + // check ".defaults" if it's not empty + if raw["defaults"] != nil { + defaultsMap, ok := raw["defaults"].(map[string]interface{}) + if !ok { + return fmt.Errorf(".defaults config is not valid") + } + + if key := findUnexpectedKey(defaultsMap, allowedDefaults); key != "" { + return fmt.Errorf("unexpected key: .defaults.%v", key) + } + + // check ".defaults.auth" + if authMap, ok := defaultsMap["auth"].(map[string]interface{}); ok { + if key := findUnexpectedKey(authMap, allowedAuthKeys); key != "" { + return fmt.Errorf("unexpected key: .defaults.auth.%v", key) + } + } + } + + // skip further config checks if ".repositories" is empty + if raw["repositories"] == nil { + return nil + } + + // check ".repositories" + reposInterface, ok := raw["repositories"].([]interface{}) + if !ok { + return fmt.Errorf(".repositories config must be an array") + } + + // check each repository in ".repositories" + for _, repoInterface := range reposInterface { + repoMap, ok := repoInterface.(map[string]interface{}) + if !ok { + return fmt.Errorf(".repositories config is not valid") + } + + if key := findUnexpectedKey(repoMap, allowedRepoKeys); key != "" { + return fmt.Errorf("unexpected key: .repositories[%v].%v", repoMap["remote"], key) + } + + // skip further repository checks if "worktrees" is empty + if repoMap["worktrees"] == nil { + continue + } + + // check "worktrees" in each repository + worktreesInterface, ok := repoMap["worktrees"].([]interface{}) + if !ok { + return fmt.Errorf("worktrees config must be an array in .repositories[%v]", repoMap["remote"]) + } + + for i, worktreeInterface := range worktreesInterface { + worktreeMap, ok := worktreeInterface.(map[string]interface{}) + if !ok { + return fmt.Errorf("worktrees config is not valid in .repositories[%v]", repoMap["remote"]) + } + + if key := findUnexpectedKey(worktreeMap, allowedWorktreeKeys); key != "" { + return fmt.Errorf("unexpected key: .repositories[%v].worktrees[%v].%v", repoMap["remote"], i, key) + } + + // Check "pathspecs" in each worktree + if pathspecsInterface, exists := worktreeMap["pathspecs"]; exists { + if _, ok := pathspecsInterface.([]interface{}); !ok { + return fmt.Errorf("pathspecs config must be an array in .repositories[%v].worktrees[%v]", repoMap["remote"], i) + } + } + } + } + + return nil +} + +// getAllowedKeys retrieves a list of allowed keys from the specified struct +func getAllowedKeys(config interface{}) []string { + var allowedKeys []string + val := reflect.ValueOf(config) + typ := reflect.TypeOf(config) + + for i := 0; i < val.NumField(); i++ { + field := typ.Field(i) + yamlTag := field.Tag.Get("yaml") + if yamlTag != "" { + allowedKeys = append(allowedKeys, yamlTag) + } + } + return allowedKeys +} + +func findUnexpectedKey(raw map[string]interface{}, allowedKeys []string) string { + for key := range raw { + if !slices.Contains(allowedKeys, key) { + return key + } + } + + return "" +} + // diffRepositories will do the diff between current state and new config and // return new repositories config and list of remote url which are not found in config func diffRepositories(repoPool *mirror.RepoPool, newConfig *mirror.RepoPoolConfig) ( diff --git a/config_test.go b/config_test.go index a8b77bf..4f176e1 100644 --- a/config_test.go +++ b/config_test.go @@ -11,7 +11,6 @@ import ( ) func Test_diffRepositories(t *testing.T) { - tests := []struct { name string initialConfig *mirror.RepoPoolConfig @@ -199,7 +198,6 @@ func Test_diffWorktrees(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if err := tt.initialRepoConf.PopulateEmptyLinkPaths(); err != nil { t.Fatalf("failed to create repo error = %v", err) } @@ -234,3 +232,181 @@ func Test_diffWorktrees(t *testing.T) { }) } } + +func Test_validateConfigYaml(t *testing.T) { + tests := []struct { + name string + yamlData []byte + wantError bool + }{ + { + name: "valid - full config", + yamlData: []byte(` +defaults: + root: /tmp/git-mirror + link_root: /tmp/links + interval: 30s + mirror_timeout: 2m + git_gc: always + auth: + ssh_key_path: /etc/git-secret/ssh + ssh_known_hosts_path: /etc/git-secret/known_hosts + +repositories: + - remote: https://github.com/utilitywarehouse/git-mirror + worktrees: + - link: aaa + ref: main + - link: bbb + ref: main + - remote: https://github.com/utilitywarehouse/another-repo + root: /some/other/location + link_root: /some/path + interval: 1m + mirror_timeout: 5m + git_gc: always + auth: + ssh_key_path: /some/other/location + ssh_known_hosts_path: /some/other/location + worktrees: + - link: alerts + ref: main + pathspecs: + - path + - path2/*.yaml +`), + wantError: false, + }, + { + name: "valid - empty config", + yamlData: []byte(` +`), + wantError: false, + }, + { + name: "valid - defaults config only", + yamlData: []byte(` +defaults: +`), + wantError: false, + }, + { + name: "valid - repositories config only", + yamlData: []byte(` +repositories: +`), + wantError: false, + }, + { + name: "invalid - unexpected key", + yamlData: []byte(` +not-valid: + test: test + +defaults: + root: /tmp/git-mirror + +repositories: + - remote: https://github.com/utilitywarehouse/git-mirror +`), + wantError: true, + }, + { + name: "invalid - unexpected key in defaults", + yamlData: []byte(` +defaults: + root: /tmp/git-mirror + not_valid: test + +repositories: + - remote: https://github.com/utilitywarehouse/git-mirror +`), + wantError: true, + }, + { + name: "invalid - unexpected key in auth", + yamlData: []byte(` +defaults: + root: /tmp/git-mirror + auth: + not_valid: test + +repositories: + - remote: https://github.com/utilitywarehouse/git-mirror +`), + wantError: true, + }, + { + name: "invalid - unexpected key in repositories", + yamlData: []byte(` +defaults: + root: /tmp/git-mirror + +repositories: + - remote: https://github.com/utilitywarehouse/git-mirror + not_valid: test +`), + wantError: true, + }, + { + name: "invalid - unexpected key in repository worktrees", + yamlData: []byte(` +defaults: + root: /tmp/git-mirror + +repositories: + - remote: https://github.com/utilitywarehouse/git-mirror + worktrees: + - link: aaa + not_valid: test +`), + wantError: true, + }, + { + name: "invalid - repositories is not an array", + yamlData: []byte(` +defaults: + root: /tmp/git-mirror + +repositories: https://github.com/utilitywarehouse/git-mirror +`), + wantError: true, + }, + { + name: "invalid - worktrees is not an array", + yamlData: []byte(` +defaults: + root: /tmp/git-mirror + +repositories: + - remote: https://github.com/utilitywarehouse/git-mirror + worktrees: test +`), + wantError: true, + }, + { + name: "invalid - pathspecs is not an array", + yamlData: []byte(` +defaults: + root: /tmp/git-mirror + +repositories: + - remote: https://github.com/utilitywarehouse/git-mirror + worktrees: + - link: aaa + not_valid: test + pathspecs: readme.md +`), + wantError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateConfigYaml(tt.yamlData) + if (err != nil) != tt.wantError { + t.Errorf("validateConfigYaml() error = %v, wantError %v", err, tt.wantError) + } + }) + } +}