diff --git a/pkg/configStorage/aliase.go b/pkg/configStorage/aliase.go new file mode 100644 index 0000000000000000000000000000000000000000..a56a913d92b93c49d497840321693d132a5f79c6 --- /dev/null +++ b/pkg/configStorage/aliase.go @@ -0,0 +1,49 @@ +package configStorage + +import ( + "os" + "path" + + "gitlab.com/gitlab-org/cli/pkg/glConfig" + "gopkg.in/yaml.v3" +) + +func GetAliases() (*glConfig.Aliases, error) { + aliases, err := ParseAliasesFile(AliasesConfigFile()) + if err != nil && os.IsNotExist(err) { + if err := aliases.Write(); err != nil { + return nil, err + } + return nil, err + } else if err != nil { + return nil, err + } + return aliases, nil +} + +func AliasesConfigFile() string { + return path.Join(ConfigDir(), "aliases.yml") +} + +func ParseAliasesFile(filename string) (*glConfig.Aliases, error) { + data, err := readConfigFile(filename) + if err != nil && !os.IsNotExist(err) { + return nil, err + } + + aliases, err := ParseAliasesYamlData(data) + if err != nil { + return nil, err + } + + aliases.Writer = aliasesWriter + return aliases, err +} + +func aliasesWriter(data any) error { + raw, err := yaml.Marshal(data) + if err != nil { + return err + } + return writeConfigFile(AliasesConfigFile(), raw) +} diff --git a/pkg/configStorage/config.go b/pkg/configStorage/config.go new file mode 100644 index 0000000000000000000000000000000000000000..36799465fdfa881e1a679abbcf6aa079750567e4 --- /dev/null +++ b/pkg/configStorage/config.go @@ -0,0 +1,148 @@ +package configStorage + +import ( + "fmt" + "os" + "path" + + "gitlab.com/gitlab-org/cli/pkg/glConfig" + "gopkg.in/yaml.v3" +) + +var ( + cachedConfig *glConfig.Config + cachedLocal *glConfig.Local + configError error + localError error +) + +func GetConfig() (*glConfig.Config, error) { + if cachedConfig != nil || configError != nil { + return cachedConfig, configError + } + cachedConfig, configError = ParseConfigFile(ConfigFile()) + + if os.IsNotExist(configError) { + if err := cachedConfig.Write(); err != nil { + return nil, err + } + configError = nil + } + + // Load local config + cachedLocal, localError = GetLocalConfig() + if localError == nil { + cachedLocal.Options.Merge(cachedConfig.Options) + cachedConfig.Options = cachedLocal.Options + if cachedLocal.DefaultHost != "" { + cachedConfig.DefaultHost = cachedLocal.DefaultHost + } + } + + if cachedConfig.UseEnv { + LoadConfigEnv(cachedConfig) + } + + return cachedConfig, configError +} + +func GetHost(hostname string) (*glConfig.Host, error) { + cfg, err := GetConfig() + if err != nil { + return nil, err + } + host := cfg.GetHost(hostname) + + if cfg.UseEnv { + // load host environment variables + LoadHostEnv(host) + } + + if cfg.UseKeyring { + if err := LoadKeyring(hostname, host); err != nil { + return nil, fmt.Errorf("failed to load keyring for host %q: %w", hostname, err) + } + } + + if host == nil { + return nil, fmt.Errorf("host %q not found in config", hostname) + } + return host, nil +} + +func GetLocalConfig() (*glConfig.Local, error) { + if cachedLocal != nil || localError != nil { + return cachedLocal, localError + } + return ParseLocalFile(localConfigFile()) +} + +func ConfigFile() string { + return path.Join(ConfigDir(), "config.yml") +} + +func ParseConfigFile(filename string) (*glConfig.Config, error) { + stat, err := os.Stat(filename) + // we want to check if there actually is a file, sometimes + // configs are just passed via stubs + if err == nil { + if !HasSecurePerms(stat.Mode().Perm()) { + return nil, + fmt.Errorf("%s has the permissions %o, but glab requires 600.\nConsider running `chmod 600 %s`", + filename, + stat.Mode(), + filename, + ) + } + } + + data, err := readConfigFile(filename) + if err != nil && !os.IsNotExist(err) { + return nil, err + } + + cfg, err := ParseConfigYamlData(data) + if err != nil { + return nil, err + } + + cfg.Writer = configWriter + + return cfg, err +} + +func configWriter(data any) error { + if cfg, ok := data.(*glConfig.Config); ok { + // Unset defaults for hosts + for _, h := range cfg.Hosts { + h.Options.UnSetDefaults(cfg.Options) + } + // Store hosts secrets in keyring if enabled + // this will remove the secrets from the config file + if cfg.UseKeyring { + for hostname, h := range cfg.Hosts { + if err := SaveKeyring(hostname, h); err != nil { + return fmt.Errorf("failed to save keyring for host %q: %w", h.Host, err) + } + } + } + } + + raw, err := yaml.Marshal(data) + if err != nil { + return err + } + + if cfg, ok := data.(*glConfig.Config); ok { + // Restore secrets in host config + if cfg.UseKeyring { + for hostname, h := range cfg.Hosts { + if err := LoadKeyring(hostname, h); err != nil { + return fmt.Errorf("failed to load keyring for host %q: %w", h.Host, err) + } + } + } + } + + return writeConfigFile(ConfigFile(), raw) +} diff --git a/pkg/configStorage/config_read_test.go b/pkg/configStorage/config_read_test.go new file mode 100644 index 0000000000000000000000000000000000000000..bff8ee1cb2d88233e890fdf4cd2603d9e06d46ed --- /dev/null +++ b/pkg/configStorage/config_read_test.go @@ -0,0 +1,252 @@ +package configStorage + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + "gitlab.com/gitlab-org/cli/test" +) + +func Test_ParseConfigFile(t *testing.T) { + defer stubConfig(`--- +hosts: + gitlab.com: + browser: monalisa + token: OTOKEN +aliases: +`, "", "")() + test.ClearEnvironmentVariables(t) + + host, err := GetHost("gitlab.com") + require.NoError(t, err) + require.NotNil(t, host) + + require.Equal(t, "monalisa", host.Options.Browser) + require.Equal(t, "OTOKEN", host.Token) +} + +func Test_parseConfig_multipleHosts(t *testing.T) { + defer stubConfig(`--- +hosts: + gitlab.example.com: + browser: wrongusername + token: NOTTHIS + gitlab.com: + browser: monalisa + token: OTOKEN +`, "", "")() + test.ClearEnvironmentVariables(t) + + host, err := GetHost("gitlab.com") + require.NoError(t, err) + require.NotNil(t, host) + + require.Equal(t, "monalisa", host.Options.Browser) + require.Equal(t, "OTOKEN", host.Token) + + host, err = GetHost("gitlab.example.com") + require.NoError(t, err) + require.NotNil(t, host) + + require.Equal(t, "wrongusername", host.Options.Browser) + require.Equal(t, "NOTTHIS", host.Token) +} + +func Test_parseConfig_Hosts(t *testing.T) { + defer stubConfig(`--- +hosts: + gitlab.com: + browser: monalisa + token: OTOKEN +`, "", "")() + test.ClearEnvironmentVariables(t) + + host, err := GetHost("gitlab.com") + require.NoError(t, err) + require.NotNil(t, host) + + require.Equal(t, "monalisa", host.Options.Browser) + require.Equal(t, "OTOKEN", host.Token) +} + +func Test_parseConfig_Local(t *testing.T) { + test.ClearEnvironmentVariables(t) + + defer stubConfig(`--- + +git_protocol: ssh +editor: vim +`, "", ` +git_protocol: https +editor: nano +`)() + + config, err := GetConfig() + require.NoError(t, err) + require.NotNil(t, config) + + require.Equal(t, "https", config.Options.GitProtocol) + require.Equal(t, "nano", config.Options.Editor) +} + +func Test_Get_configReadSequence(t *testing.T) { + test.ClearEnvironmentVariables(t) + + defer stubConfig(`--- + +git_protocol: ssh +editor: vim +browser: mozilla +`, "", ` +git_protocol: https +editor: +browser: chrome +`)() + + t.Setenv("BROWSER", "opera") + + config, err := GetConfig() + require.NoError(t, err) + require.NotNil(t, config) + + require.Equal(t, "https", config.Options.GitProtocol) + require.Equal(t, "vim", config.Options.Editor) + require.Equal(t, "opera", config.Options.Browser) +} + +func Test_parseConfig_AliasesFile(t *testing.T) { + defer stubConfig("", `--- +ci: pipeline ci +co: mr checkout +no: non default alias +`, "")() + aliases, err := ParseAliasesFile("aliases.yml") + require.NoError(t, err) + require.NotNil(t, aliases) + require.Len(t, aliases.All(), 3) + + a, isAlias := aliases.Get("ci") + require.True(t, isAlias) + require.Equal(t, "pipeline ci", a) + + b, isAlias := aliases.Get("co") + require.True(t, isAlias) + require.Equal(t, "mr checkout", b) + + c, isAlias := aliases.Get("no") + require.True(t, isAlias) + require.Equal(t, "non default alias", c) +} + +func Test_parseConfig_hostFallback(t *testing.T) { + defer stubConfig(`--- +git_protocol: ssh +hosts: + gitlab.com: + browser: monalisa + token: OTOKEN + gitlab.example.com: + browser: wrongusername + token: NOTTHIS + git_protocol: https +`, "", "")() + + host, err := GetHost("gitlab.example.com") + require.NoError(t, err) + require.NotNil(t, host) + require.Equal(t, "https", host.Options.GitProtocol) + + host, err = GetHost("gitlab.com") + require.NoError(t, err) + require.NotNil(t, host) + require.Equal(t, "ssh", host.Options.GitProtocol) + + host, err = GetHost("nonexist.io") + require.NoError(t, err) + require.NotNil(t, host) + require.Equal(t, "ssh", host.Options.GitProtocol) +} + +func Test_ParseConfigFilePermissions(t *testing.T) { + tests := map[string]struct { + permissions int + wantErr bool + }{ + "bad permissions": { + permissions: 0o755, + wantErr: true, + }, + "normal permissions": { + permissions: 0o600, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + tempDir := t.TempDir() + configFile := filepath.Join(tempDir, "config.yml") + + err := os.WriteFile( + configFile, + []byte("---\nhost: https://gitlab.mycompany.global"), + os.FileMode(tt.permissions), + ) + require.NoError(t, err) + + _, err = ParseConfigFile(configFile) + + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + }) + } +} + +func Test_parseConfigHostEnv(t *testing.T) { + t.Setenv("GITLAB_URI", "gitlab.mycompany.env") + + defer stubConfig(`--- + +host: gitlab.mycompany.global + +`, "", ` +host: gitlab.mycompany.local +`)() + + config, err := GetConfig() + require.NoError(t, err) + require.NotNil(t, config) + + require.Equal(t, "gitlab.mycompany.env", config.DefaultHost) +} + +func Test_parseConfigHostLocal(t *testing.T) { + defer stubConfig(`--- + +host: gitlab.mycompany.global + +`, "", ` +host: gitlab.mycompany.local +`)() + + config, err := GetConfig() + require.NoError(t, err) + require.NotNil(t, config) + + require.Equal(t, "gitlab.mycompany.local", config.DefaultHost) +} + +func Test_parseConfigHostGlobal(t *testing.T) { + defer stubConfig(`--- +host: gitlab.mycompany.org +`, "", "")() + config, err := GetConfig() + require.NoError(t, err) + require.NotNil(t, config) + + require.Equal(t, "gitlab.mycompany.org", config.DefaultHost) +} diff --git a/pkg/configStorage/config_write_test.go b/pkg/configStorage/config_write_test.go new file mode 100644 index 0000000000000000000000000000000000000000..c3d55c079958f81724adab483c6759659372d033 --- /dev/null +++ b/pkg/configStorage/config_write_test.go @@ -0,0 +1,158 @@ +package configStorage + +import ( + "bytes" + "os" + "testing" + + "github.com/MakeNowJust/heredoc/v2" + "github.com/stretchr/testify/require" + "github.com/zalando/go-keyring" + "gitlab.com/gitlab-org/cli/test" +) + +func Test_fileConfig_Set(t *testing.T) { + test.ClearEnvironmentVariables(t) + + defer stubConfig(`--- +git_protocol: ssh +editor: vim +hosts: + gitlab.com: + token: + git_protocol: https + browser: user +`, "", "")() + + mainBuf := bytes.Buffer{} + aliasesBuf := bytes.Buffer{} + defer stubWriteConfig(&mainBuf, &aliasesBuf)() + + c, err := GetConfig() + require.NoError(t, err) + + c.Options.Editor = "nano" + + host, err := GetHost("gitlab.com") + require.NoError(t, err) + require.NotNil(t, host) + + host.Options.GitProtocol = "ssh" + host.Options.Browser = "hubot" + + host, err = GetHost("example.com") + require.NoError(t, err) + require.NotNil(t, host) + + host.Options.Browser = "testUser" + + err = c.Write() + require.NoError(t, err) + + expected := heredoc.Doc(` +check_update: true +telemetry: true +use_environment: true +api_protocol: https +git_protocol: ssh +glamour_style: dark +editor: nano +host: gitlab.com +hosts: + example.com: + host: example.com + api_host: example.com + browser: testUser + gitlab.com: + browser: hubot +`) + require.Equal(t, expected, mainBuf.String()) +} + +func Test_defaultConfig(t *testing.T) { + defer stubConfig("", "", "")() + + mainBuf := bytes.Buffer{} + aliasesBuf := bytes.Buffer{} + defer stubWriteConfig(&mainBuf, &aliasesBuf)() + + cfg, err := GetConfig() + require.NoError(t, err) + require.NotNil(t, cfg) + + require.Equal(t, "ssh", cfg.Options.GitProtocol) + require.Equal(t, os.Getenv("EDITOR"), cfg.Options.Editor) + + aliases, err := GetAliases() + require.Nil(t, err) + require.Equal(t, len(aliases.All()), 2) + + expansion, _ := aliases.Get("co") + require.Equal(t, expansion, "mr checkout") +} + +func Test_getFromKeyring(t *testing.T) { + defer stubConfig("", "", "")() + + mainBuf := bytes.Buffer{} + aliasesBuf := bytes.Buffer{} + defer stubWriteConfig(&mainBuf, &aliasesBuf)() + + cfg, err := GetConfig() + require.NoError(t, err) + require.NotNil(t, cfg) + + host, err := GetHost("gitlab.com") + require.NoError(t, err) + require.NotNil(t, host) + + host.Token = "" + err = host.Write() + require.NoError(t, err) + + cfg.UseKeyring = true + + keyring.MockInit() + err = keyring.Set("glab:gitlab.com", "token", "glpat-1234") + require.NoError(t, err) + + host, err = GetHost("gitlab.com") + require.NoError(t, err) + require.NotNil(t, host) + require.Equal(t, "", host.Token) + + err = keyring.Set("glab:gitlab.com", "", "glpat-1234") + require.NoError(t, err) + + host, err = GetHost("gitlab.com") + require.NoError(t, err) + require.NotNil(t, host) + require.Equal(t, "glpat-1234", host.Token) + + host.Token = "glpat-5678" + host.Oauth2.ClientId = "client-id-1234" + err = host.Write() + require.NoError(t, err) + + val, err := keyring.Get("glab:gitlab.com:oauth2_client_id", "") + require.NoError(t, err) + require.Equal(t, "client-id-1234", val, "wrong client_id value from keyring") + + val, err = keyring.Get("glab:gitlab.com", "") + require.NoError(t, err) + require.Equal(t, "glpat-5678", val, "wrong token value from keyring") + + host, err = GetHost("gitlab.com") + require.NoError(t, err) + require.NotNil(t, host) + require.Equal(t, "glpat-5678", host.Token) + require.Equal(t, "client-id-1234", host.Oauth2.ClientId) + + mainBuf.Reset() + err = host.Write() + require.NoError(t, err) + + // ensure no sensitive data is written to config file + require.NotContains(t, mainBuf.String(), "glpat-") + require.NotContains(t, mainBuf.String(), "client-id-") +} diff --git a/pkg/configStorage/env.go b/pkg/configStorage/env.go new file mode 100644 index 0000000000000000000000000000000000000000..483ad8901618637b816cdfbb628d0e7193905af8 --- /dev/null +++ b/pkg/configStorage/env.go @@ -0,0 +1,99 @@ +package configStorage + +import ( + "os" + "reflect" + "strings" + + "gitlab.com/gitlab-org/cli/pkg/glConfig" +) + +func getEnvString(keys ...string) (string, bool) { + for _, key := range keys { + if value, ok := os.LookupEnv(key); ok && value != "" { + return value, true + } + } + return "", false +} + +func getEnvBool(keys ...string) (bool, bool) { + for _, key := range keys { + if value, ok := os.LookupEnv(key); ok && value != "" { + return (value == "true" || value == "1"), true + } + } + return false, false +} + +func LoadConfigEnv(cfg *glConfig.Config) { + LoadEnv(cfg) + if value, ok := getEnvBool("NO_PROMPT", "PROMPT_DISABLED"); ok { + cfg.NoPrompt = value + } + if value, ok := getEnvBool("GLAB_SEND_TELEMETRY"); ok { + cfg.Telemetry = value + } + if value, ok := getEnvString("GLAB_EDITOR", "VISUAL", "EDITOR"); ok { + cfg.Options.Editor = value + } + if value, ok := getEnvString("GITLAB_HOST", "GITLAB_URI", "GL_HOST"); ok { + cfg.DefaultHost = value + } +} + +func LoadHostEnv(host *glConfig.Host) { + LoadEnv(host) + if value, ok := getEnvString("GITLAB_API_HOST"); ok { + host.APIHost = value + } + if value, ok := getEnvString("GITLAB_HOST", "GITLAB_URI", "GL_HOST"); ok { + host.Host = value + } + if value, ok := getEnvString("GITLAB_TOKEN", "GITLAB_ACCESS_TOKEN", "OAUTH_TOKEN"); ok { + host.Token = value + } + if value, ok := getEnvString("GITLAB_CLIENT_ID"); ok { + host.Oauth2.ClientId = value + } +} + +// Load Env variables by the yaml key in upper case +func LoadEnv(s any) { + v := reflect.ValueOf(s).Elem() + for i := 0; i < v.NumField(); i++ { + field := v.Field(i) + tags := v.Type().Field(i).Tag + + y, ok := tags.Lookup("yaml") + if !ok { + continue + } + ydata := strings.Split(y, ",") + name := ydata[0] + + if name == "-" { + continue + } + + if field.Kind() == reflect.Struct { + value := field.Addr().Interface() + LoadEnv(value) + continue + } + + if name == "" { + continue + } + + if field.Kind() == reflect.String { + if value, ok := getEnvString(strings.ToUpper(name)); ok { + field.SetString(value) + } + } else if field.Kind() == reflect.Bool { + if value, ok := getEnvBool(strings.ToUpper(name)); ok { + field.SetBool(value) + } + } + } +} diff --git a/pkg/configStorage/file.go b/pkg/configStorage/file.go new file mode 100644 index 0000000000000000000000000000000000000000..84251ab828ad8916d5677afae2df4550c2f56c87 --- /dev/null +++ b/pkg/configStorage/file.go @@ -0,0 +1,76 @@ +package configStorage + +import ( + "errors" + "fmt" + "os" + "path" + "path/filepath" + "syscall" + + "github.com/mitchellh/go-homedir" +) + +// ConfigDir returns the config directory +func ConfigDir() string { + if glabDir := os.Getenv("GLAB_CONFIG_DIR"); glabDir != "" { + return glabDir + } + + usrConfigHome := os.Getenv("XDG_CONFIG_HOME") + if usrConfigHome == "" { + if home := os.Getenv("HOME"); home != "" { + usrConfigHome = filepath.Join(home, ".config") + } + } + if usrConfigHome == "" { + usrConfigHome, _ = homedir.Expand("~/.config") + } + return filepath.Join(usrConfigHome, "glab-cli") +} + +var readConfigFile = func(filename string) ([]byte, error) { + data, err := os.ReadFile(filename) + if err != nil { + return nil, pathError(err) + } + + return data, nil +} + +var writeConfigFile = func(filename string, data []byte) error { + err := os.MkdirAll(path.Dir(filename), 0o750) + if err != nil { + return pathError(err) + } + _, err = os.ReadFile(filename) + if err != nil && !os.IsNotExist(err) { + return err + } + err = WriteFile(filename, data, 0o600) + return err +} + +func pathError(err error) error { + var pathError *os.PathError + if errors.As(err, &pathError) && errors.Is(pathError.Err, syscall.ENOTDIR) { + if p := findRegularFile(pathError.Path); p != "" { + return fmt.Errorf("remove or rename regular file `%s` (must be a directory)", p) + } + } + return err +} + +func findRegularFile(p string) string { + for { + if s, err := os.Stat(p); err == nil && s.Mode().IsRegular() { + return p + } + newPath := path.Dir(p) + if newPath == p || newPath == "/" || newPath == "." { + break + } + p = newPath + } + return "" +} diff --git a/pkg/configStorage/keyring.go b/pkg/configStorage/keyring.go new file mode 100644 index 0000000000000000000000000000000000000000..e76c27691b27438c28eff1cbd6fd5173cedffa20 --- /dev/null +++ b/pkg/configStorage/keyring.go @@ -0,0 +1,93 @@ +package configStorage + +import ( + "fmt" + "reflect" + "strings" + + "github.com/zalando/go-keyring" +) + +func LoadKeyring(hostname string, s any) error { + v := reflect.ValueOf(s).Elem() + for i := 0; i < v.NumField(); i++ { + field := v.Field(i) + tags := v.Type().Field(i).Tag + + if field.Kind() == reflect.Struct { + value := field.Addr().Interface() + err := LoadKeyring(hostname, value) + if err != nil { + return err + } + continue + } + + k, ok := tags.Lookup("keyring") + if !ok { + continue + } + kdata := strings.Split(k, ",") + name := kdata[0] + + if name == "-" { + continue + } + + if field.Kind() == reflect.String { + service := fmt.Sprintf("glab:%s", hostname) + if name != "" { + service = fmt.Sprintf("glab:%s:%s", hostname, name) + } + if value, err := keyring.Get(service, ""); err != nil && err != keyring.ErrNotFound { + return err + } else { + field.SetString(value) + } + } + } + return nil +} + +func SaveKeyring(hostname string, s any) error { + v := reflect.ValueOf(s).Elem() + for i := 0; i < v.NumField(); i++ { + field := v.Field(i) + tags := v.Type().Field(i).Tag + + if field.Kind() == reflect.Struct { + value := field.Addr().Interface() + err := SaveKeyring(hostname, value) + if err != nil { + return err + } + continue + } + + k, ok := tags.Lookup("keyring") + if !ok { + continue + } + kdata := strings.Split(k, ",") + name := kdata[0] + + if name == "-" { + continue + } + + if field.Kind() == reflect.String { + service := fmt.Sprintf("glab:%s", hostname) + if name != "" { + service = fmt.Sprintf("glab:%s:%s", hostname, name) + } + value := field.String() + if err := keyring.Set(service, "", value); err != nil && err != keyring.ErrNotFound { + return err + } else { + field.SetString("") + } + } + } + + return nil +} diff --git a/pkg/configStorage/local.go b/pkg/configStorage/local.go new file mode 100644 index 0000000000000000000000000000000000000000..b9199f6d67adbcc3fafbffe8f04808411f301ae7 --- /dev/null +++ b/pkg/configStorage/local.go @@ -0,0 +1,82 @@ +package configStorage + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + + "gitlab.com/gitlab-org/cli/pkg/glConfig" +) + +// for later use we might prefer relative paths +// GitDir will find a directory or just return ".git" +func GitDir(preferRelative bool) []string { + var err error + var out strings.Builder + + // `git rev-parse --git-dir` since git v1.2.2 + cmd := exec.Command("git", "rev-parse", "--git-dir") + + cmd.Stdout = &out + err = cmd.Run() + if err != nil { + // should fail + return []string{".git"} + } + + gitDir := strings.TrimSpace(out.String()) + + if !filepath.IsAbs(gitDir) { + // gitDir is relative + if !preferRelative { + // but we prefer absolute + workDir, err := os.Getwd() + if err == nil { + return []string{workDir, gitDir} + } + } + // relative should work + return []string{gitDir} + } else { + // gitDir is absolute + if preferRelative { + // but we prefer relative + var relativeDir string + workDir, err := os.Getwd() + if err == nil { + relativeDir, err = filepath.Rel(workDir, gitDir) + } + if err == nil { + return []string{relativeDir} + } + } + // absolute should work + return []string{gitDir} + } +} + +// LocalConfigDir returns the local config path in map +// which must be joined for complete path +var LocalConfigDir = func() []string { + return append(GitDir(true), "glab-cli") +} + +// localConfigFile returns the config file name with full path +var localConfigFile = func() string { + configFile := append(LocalConfigDir(), "config.yml") + return filepath.Join(configFile...) +} + +func ParseLocalFile(filename string) (*glConfig.Local, error) { + data, err := readConfigFile(filename) + if err != nil { + return nil, err + } + + local, err := ParseLocalYamlData(data) + if err != nil { + return nil, err + } + return local, err +} diff --git a/pkg/configStorage/parseYaml.go b/pkg/configStorage/parseYaml.go new file mode 100644 index 0000000000000000000000000000000000000000..373f023b80c6b89b29fb3948036cf71b743a60a1 --- /dev/null +++ b/pkg/configStorage/parseYaml.go @@ -0,0 +1,39 @@ +package configStorage + +import ( + "gitlab.com/gitlab-org/cli/pkg/glConfig" + "gopkg.in/yaml.v3" +) + +func ParseConfigYamlData(data []byte) (*glConfig.Config, error) { + cfg := glConfig.DefaultConfig() + + err := yaml.Unmarshal(data, cfg) + if err != nil { + return nil, err + } + + return cfg, nil +} + +func ParseAliasesYamlData(data []byte) (*glConfig.Aliases, error) { + aliases := glConfig.DefaultAliases() + + err := yaml.Unmarshal(data, aliases) + if err != nil { + return nil, err + } + + return aliases, nil +} + +func ParseLocalYamlData(data []byte) (*glConfig.Local, error) { + local := glConfig.DefaultLocal() + + err := yaml.Unmarshal(data, local) + if err != nil { + return nil, err + } + + return local, nil +} diff --git a/pkg/configStorage/perms.go b/pkg/configStorage/perms.go new file mode 100644 index 0000000000000000000000000000000000000000..46695e8a8285a7545d3547720b0ca53bb4a5cd10 --- /dev/null +++ b/pkg/configStorage/perms.go @@ -0,0 +1,14 @@ +package configStorage + +import ( + "io/fs" + "runtime" +) + +func HasSecurePerms(m fs.FileMode) bool { + if runtime.GOOS == "windows" { + return true + } else { + return m == 0o600 + } +} diff --git a/pkg/configStorage/testing.go b/pkg/configStorage/testing.go new file mode 100644 index 0000000000000000000000000000000000000000..6e64c67576f9a0740c17ee06cc135d1d300aa31b --- /dev/null +++ b/pkg/configStorage/testing.go @@ -0,0 +1,63 @@ +package configStorage + +import ( + "fmt" + "io" + "os" + "path" +) + +func stubWriteConfig(wc io.Writer, wh io.Writer) func() { + orig := writeConfigFile + writeConfigFile = func(fn string, data []byte) error { + switch path.Base(fn) { + case "config.yml": + _, err := wc.Write(data) + return err + case "aliases.yml": + _, err := wh.Write(data) + return err + default: + return fmt.Errorf("write to unstubbed file: %q", fn) + } + } + return func() { + writeConfigFile = orig + } +} + +func stubConfig(main, aliases, local string) func() { + orig := readConfigFile + origLoc := localConfigFile + localConfigFile = func() string { + return "local.yml" + } + readConfigFile = func(fn string) ([]byte, error) { + switch path.Base(fn) { + case "config.yml": + if main == "" { + return []byte(nil), os.ErrNotExist + } else { + return []byte(main), nil + } + case "aliases.yml": + if aliases == "" { + return []byte(nil), os.ErrNotExist + } else { + return []byte(aliases), nil + } + case "local.yml": + if local == "" { + return []byte(nil), os.ErrNotExist + } else { + return []byte(local), nil + } + default: + return []byte(nil), fmt.Errorf("read from unstubbed file: %q", fn) + } + } + return func() { + readConfigFile = orig + localConfigFile = origLoc + } +} diff --git a/pkg/configStorage/writefile.go b/pkg/configStorage/writefile.go new file mode 100644 index 0000000000000000000000000000000000000000..eaeed35f61fecb2384000b3de5f33e2b4faf3e12 --- /dev/null +++ b/pkg/configStorage/writefile.go @@ -0,0 +1,22 @@ +//go:build !windows +// +build !windows + +package configStorage + +import ( + "os" + "path/filepath" + + "github.com/google/renameio/v2" +) + +// WriteFile to the path +// If the path is smylink it will write to the symlink +func WriteFile(filename string, data []byte, perm os.FileMode) error { + pathToSymlink, err := filepath.EvalSymlinks(filename) + if err == nil { + filename = pathToSymlink + } + + return renameio.WriteFile(filename, data, perm) +} diff --git a/pkg/configStorage/writefile_test.go b/pkg/configStorage/writefile_test.go new file mode 100644 index 0000000000000000000000000000000000000000..6cf5ae6cbccc6f22dc73c38fd1c1572ad4a0e521 --- /dev/null +++ b/pkg/configStorage/writefile_test.go @@ -0,0 +1,79 @@ +package configStorage + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func Test_WriteFile(t *testing.T) { + dir, err := os.MkdirTemp("", "") + if err != nil { + t.Skipf("unexpected error while creating temporary directory = %s", err) + } + t.Cleanup(func() { + os.RemoveAll(dir) + }) + + testCases := []struct { + name string + filePath string + content string + permissions os.FileMode + isSymlink bool + }{ + { + name: "regular", + filePath: "test-file", + content: "profclems/glab", + permissions: 0o644, + isSymlink: false, + }, + { + name: "config", + filePath: "config-file", + content: "profclems/glab/config", + permissions: 0o600, + isSymlink: false, + }, + { + name: "symlink", + filePath: "test-file", + content: "profclems/glab/symlink", + permissions: 0o644, + isSymlink: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + fullPath := filepath.Join(dir, tc.filePath) + + if tc.isSymlink { + symPath := filepath.Join(dir, "test-symlink") + require.Nil(t, os.Symlink(tc.filePath, symPath), "failed to create a symlink") + fullPath = symPath + } + + require.Nilf(t, + WriteFile(fullPath, []byte(tc.content), tc.permissions), + "unexpected error for testCase %q", tc.name, + ) + + result, err := os.ReadFile(fullPath) + require.Nilf(t, err, "failed to read file %q due to %q", fullPath, err) + require.Equal(t, tc.content, string(result)) + + fileInfo, err := os.Lstat(fullPath) + require.Nil(t, err, "failed to get info about the file", err) + + if tc.isSymlink { + require.Equal(t, os.ModeSymlink, fileInfo.Mode()&os.ModeSymlink, "this file should be a symlink") + } else { + require.Equal(t, tc.permissions, fileInfo.Mode()) + } + }) + } +} diff --git a/pkg/configStorage/writefile_windows.go b/pkg/configStorage/writefile_windows.go new file mode 100644 index 0000000000000000000000000000000000000000..041a6d6306c01b5a3d040c71ed9b8cdd74aa5edb --- /dev/null +++ b/pkg/configStorage/writefile_windows.go @@ -0,0 +1,13 @@ +package configStorage + +import ( + "os" +) + +// Note: this is not atomic, but apparently there's no way to atomically +// +// replace a file on windows which is why renameio doesn't support +// windows. +func WriteFile(filename string, data []byte, perm os.FileMode) error { + return os.WriteFile(filename, data, perm) +} diff --git a/pkg/glConfig/aliase.go b/pkg/glConfig/aliase.go new file mode 100644 index 0000000000000000000000000000000000000000..12f6af24d75e17337856dba9febb7502dda3af7a --- /dev/null +++ b/pkg/glConfig/aliase.go @@ -0,0 +1,42 @@ +package glConfig + +import ( + "fmt" +) + +type Aliases struct { + Aliases map[string]string `yaml:",inline"` + Writer Writer `yaml:"-"` +} + +func (a Aliases) Get(alias string) (string, bool) { + val, ok := a.Aliases[alias] + return val, ok +} + +func (a Aliases) Set(alias, expansion string) error { + a.Aliases[alias] = expansion + + err := a.Write() + if err != nil { + return fmt.Errorf("failed to write config: %w", err) + } + + return nil +} + +func (a Aliases) Delete(alias string) error { + delete(a.Aliases, alias) + return a.Write() +} + +func (a Aliases) Write() error { + if a.Writer == nil { + return fmt.Errorf("no writer defined for aliases") + } + return a.Writer(a) +} + +func (a Aliases) All() map[string]string { + return a.Aliases +} diff --git a/pkg/glConfig/config.go b/pkg/glConfig/config.go new file mode 100644 index 0000000000000000000000000000000000000000..72ce3c44afbb45e71af4fd38a91025eda8ae85cc --- /dev/null +++ b/pkg/glConfig/config.go @@ -0,0 +1,55 @@ +package glConfig + +import "fmt" + +type Config struct { + Debug bool `yaml:"debug,omitempty"` + CheckUpdate bool `yaml:"check_update,omitempty"` + LastUpdateCheckTimestamp string `yaml:"last_update_check_timestamp,omitempty"` + DisplayHyperlinks bool `yaml:"display_hyperlinks,omitempty"` + NoPrompt bool `yaml:"no_prompt,omitempty"` + Telemetry bool `yaml:"telemetry,omitempty"` + UseKeyring bool `yaml:"use_keyring,omitempty"` + UseEnv bool `yaml:"use_environment,omitempty"` + + Options Options `yaml:",inline"` + + DefaultHost string `yaml:"host,omitempty"` + Hosts map[string]*Host `yaml:"hosts,omitempty"` + + Writer Writer `yaml:"-"` +} + +func (c *Config) GetHost(hostname string) *Host { + if hostname == "" { + hostname = c.DefaultHost + } + if h, ok := c.Hosts[hostname]; ok { + h.Options.Merge(c.Options) + h.Writer = func(_ any) error { + return c.Writer(c) + } + return h + } + + // If the host is not found, return a new Host with the default options + newHost := &Host{ + Host: hostname, + APIHost: hostname, + Options: c.Options, + Writer: func(_ any) error { + return c.Writer(c) + }, + } + + c.Hosts[hostname] = newHost + + return newHost +} + +func (c *Config) Write() error { + if c.Writer == nil { + return fmt.Errorf("no writer defined for config") + } + return c.Writer(c) +} diff --git a/pkg/glConfig/default.go b/pkg/glConfig/default.go new file mode 100644 index 0000000000000000000000000000000000000000..d53d26bd0c3e415680cd07297a70a51fde29d1e6 --- /dev/null +++ b/pkg/glConfig/default.go @@ -0,0 +1,36 @@ +package glConfig + +func DefaultConfig() *Config { + return &Config{ + CheckUpdate: true, + Telemetry: true, + UseEnv: true, + + Options: Options{ + GitProtocol: "ssh", + APIProtocol: "https", + GlamourStyle: "dark", + }, + + DefaultHost: "gitlab.com", + Hosts: map[string]*Host{ + "gitlab.com": { + APIHost: "gitlab.com", + }, + }, + } +} + +func DefaultAliases() *Aliases { + return &Aliases{ + Aliases: map[string]string{ + "ci": "pipeline ci", + "co": "mr checkout", + }, + } +} + +// Local configuration is empty by default +func DefaultLocal() *Local { + return &Local{} +} diff --git a/pkg/glConfig/host.go b/pkg/glConfig/host.go new file mode 100644 index 0000000000000000000000000000000000000000..ea632d48b50589440426f74dbead3d41020e35ee --- /dev/null +++ b/pkg/glConfig/host.go @@ -0,0 +1,39 @@ +package glConfig + +import "fmt" + +type Host struct { + Host string `yaml:"host,omitempty"` + APIHost string `yaml:"api_host,omitempty"` + + Token string `yaml:"token,omitempty" keyring:""` + JobToken string `yaml:"job_token,omitempty" keyring:"job_token"` + + Oauth2 Oauth2 `yaml:",inline"` + Options Options `yaml:",inline"` + + // TLS settings + TLSInsecureSkipVerify bool `yaml:"skip_tls_verify,omitempty"` + CaCert string `yaml:"ca_cert,omitempty"` + ClientCert string `yaml:"client_cert,omitempty"` + ClientKey string `yaml:"client_key,omitempty"` + + Writer Writer `yaml:"-"` +} + +type Oauth2 struct { + ClientId string `yaml:"client_id,omitempty" keyring:"oauth2_client_id"` + ExpiryDate string `yaml:"oauth2_expiry_date,omitempty" keyring:"oauth2_expiry_date"` + RefreshToken string `yaml:"oauth2_refresh_token,omitempty" keyring:"oauth2_refresh_token"` +} + +func (host *Host) Write() error { + if host.Writer == nil { + return fmt.Errorf("no writer defined for config") + } + return host.Writer(host) +} + +func (oauth2 *Oauth2) IsOauth2() bool { + return oauth2.ClientId != "" && oauth2.ExpiryDate != "" && oauth2.RefreshToken != "" +} diff --git a/pkg/glConfig/local.go b/pkg/glConfig/local.go new file mode 100644 index 0000000000000000000000000000000000000000..de5d4c2fef6f8f125b337b9de5a3daa967efba7d --- /dev/null +++ b/pkg/glConfig/local.go @@ -0,0 +1,6 @@ +package glConfig + +type Local struct { + Options Options `yaml:",inline"` + DefaultHost string `yaml:"host"` +} diff --git a/pkg/glConfig/options.go b/pkg/glConfig/options.go new file mode 100644 index 0000000000000000000000000000000000000000..092e6684efd783ca13e5976ffb5011e188852c6b --- /dev/null +++ b/pkg/glConfig/options.go @@ -0,0 +1,36 @@ +package glConfig + +import "reflect" + +type Options struct { + APIProtocol string `yaml:"api_protocol,omitempty"` + GitProtocol string `yaml:"git_protocol,omitempty"` + GlamourStyle string `yaml:"glamour_style,omitempty"` + Browser string `yaml:"browser,omitempty"` + Editor string `yaml:"editor,omitempty"` + Pager string `yaml:"glab_pager,omitempty"` + BranchPrefix string `yaml:"branch_prefix,omitempty"` +} + +// Set all empty fields in Options to their default values +func (o *Options) Merge(defaults Options) { + v := reflect.ValueOf(o).Elem() + d := reflect.ValueOf(defaults) + for i := 0; i < v.NumField(); i++ { + field := v.Field(i) + if field.Kind() == reflect.String && field.String() == "" { + field.SetString(d.Field(i).String()) + } + } +} + +func (o *Options) UnSetDefaults(defaults Options) { + v := reflect.ValueOf(o).Elem() + d := reflect.ValueOf(defaults) + for i := 0; i < v.NumField(); i++ { + field := v.Field(i) + if field.Kind() == reflect.String && field.String() == d.Field(i).String() { + field.SetString("") + } + } +} diff --git a/pkg/glConfig/writer.go b/pkg/glConfig/writer.go new file mode 100644 index 0000000000000000000000000000000000000000..f3767d7119287b067cb5443d5d335df1e5a3311c --- /dev/null +++ b/pkg/glConfig/writer.go @@ -0,0 +1,3 @@ +package glConfig + +type Writer func(data any) error